add the ablitiy to truncate series on the site

This commit is contained in:
Ross
2023-12-18 11:54:00 +00:00
parent e1c81e04b1
commit 7b21b1ad6f
7 changed files with 255 additions and 77 deletions
+20
View File
@@ -180,6 +180,26 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes) return len(dupes)
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end:int):
print(start, end)
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return {"status": "permission denied"}
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= start and n <=end:
continue
else:
print(n, image)
image.removed=True
image.image = None
image.save()
images_removed.append(image.pk)
return {"status": "success", "images_removed": images_removed}
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema]) @router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
def get_cases_user(request): def get_cases_user(request):
@@ -0,0 +1,23 @@
# Generated by Django 4.1.4 on 2023-12-18 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0031_seriesimage_replaced'),
]
operations = [
migrations.AddField(
model_name='series',
name='modified',
field=models.CharField(choices=[('NO', 'Not modified'), ('TR', 'Truncated'), ('RE', 'Resliced/resampled')], default='NO', max_length=2),
),
migrations.AddField(
model_name='seriesimage',
name='removed',
field=models.BooleanField(default=False, help_text='Set to true if the file this object refers to has been removed (either from series truncation or merging)'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.1.4 on 2023-12-18 11:47
import atlas.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0032_series_modified_seriesimage_removed'),
]
operations = [
migrations.AlterField(
model_name='seriesimage',
name='image',
field=models.FileField(blank=True, null=True, upload_to=atlas.models.image_directory_path),
),
]
+2 -16
View File
@@ -466,7 +466,7 @@ def extract_image_dicom_json_from_ds(ds, url, image_index):
class SeriesImage(SeriesImageBase): class SeriesImage(SeriesImageBase):
image = models.FileField(upload_to=image_directory_path) image = models.FileField(upload_to=image_directory_path, null=True)
series = models.ForeignKey( series = models.ForeignKey(
"Series", related_name="images", on_delete=models.CASCADE, null=True "Series", related_name="images", on_delete=models.CASCADE, null=True
) )
@@ -479,11 +479,6 @@ class SeriesImage(SeriesImageBase):
help_text="Reference to the object that has replaced this one.", help_text="Reference to the object that has replaced this one.",
) )
removed = models.BooleanField(
default=False,
help_text="Set to true if the file this object refers to has been removed (either from series truncation or merging)",
)
def get_dicom_data(self): def get_dicom_data(self):
try: try:
with pydicom.dcmread(self.image) as d: with pydicom.dcmread(self.image) as d:
@@ -563,15 +558,6 @@ class Series(SeriesBase):
related_name="series", related_name="series",
) )
class SeriesModifiedChocies(models.TextChoices):
NO = "NO", "Not modified",
TR = "TR", "Truncated",
RE = "RE", "Resliced/resampled",
modified = models.CharField(max_length=2, default=SeriesModifiedChocies.NO, choices=SeriesModifiedChocies.choices)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True) series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
@@ -603,7 +589,7 @@ class Series(SeriesBase):
# TODO: clean up (this is rather convoluted....) # TODO: clean up (this is rather convoluted....)
image: SeriesImage image: SeriesImage
for series_n, image in enumerate(self.images.all()): for series_n, image in enumerate(self.images.filter(removed=False)):
ds = image.get_dicom_data() ds = image.get_dicom_data()
if series_n == 0: if series_n == 0:
for tag in to_keep: for tag in to_keep:
+91 -45
View File
@@ -10,47 +10,64 @@
This series is not associated with any cases. This series is not associated with any cases.
{% endif %} {% endif %}
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ series.get_image_url_array }}" data-annotations=''> {% with image_url_array_and_count=series.get_image_url_array_and_count %}
</div> <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
{% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button>
{% endif %}
<button id="reset-viewport-button">Reset viewport</button>
<div id="finding-form">
<div class="hide" id="hidden-form">
<form method="post" id="series_finding_form">
{% csrf_token %}
{{series_finding_form.management_form}}
<div>
<div>Description: {{ series_finding_form.description }}</div>
<div>Findings: {{ series_finding_form.findings }}</div>
<div>Structures: {{ series_finding_form.structures }}</div>
</div>
<input type="submit" value="Submit">
<button id="cancel-add-finding-button">Cancel</button>
</form>
</div> </div>
</div>
<details open> {% if editing_finding < 1 %}
<summary>Findings</summary> <button id="add-finding-button">Add finding</button>
{% endif %}
{% for finding in series.findings.all %} <button id="reset-viewport-button">Reset viewport</button>
<div class="finding-box"> <div id="finding-form">
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}} <div class="hide" id="hidden-form">
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}}>Click to view</button> <form method="post" id="series_finding_form">
<span class="view-finding-details"> {% csrf_token %}
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br /> {{series_finding_form.management_form}}
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br /> <div>
Description: {{finding.description}}<br /> <div>Description: {{ series_finding_form.description }}</div>
</span> <div>Findings: {{ series_finding_form.findings }}</div>
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a> <div>Structures: {{ series_finding_form.structures }}</div>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a> </div>
<input type="submit" value="Submit">
<button id="cancel-add-finding-button">Cancel</button>
</form>
</div> </div>
</div>
{% endfor %} <details open>
</details> <summary>Findings</summary>
{% for finding in series.findings.all %}
<div class="finding-box">
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}}
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}}>Click to view</button>
<span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
Description: {{finding.description}}<br />
</span>
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
</div>
{% endfor %}
</details>
<details>
<summary>Truncate series</summary>
<p>
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required).
</p>
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
<button id="truncate-button">Trucate series</button>
<div id="trucate-output"></div>
</details>
{% endwith %}
<div>Author: {{ series.get_author_display }}</div> <div>Author: {{ series.get_author_display }}</div>
@@ -72,20 +89,20 @@
<summary> <summary>
Image details Image details
</summary> </summary>
{% for image in series.images.all %} {% for image in series.get_images %}
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}} [{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
{% if image.image %} {% if image.image %}
[{{image.image.size|filesizeformat}}] [{{image.image.size|filesizeformat}}]
{% endif %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_md5_hash}} ({{image.is_dicom}})
<br /> <br />
{% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %} {% comment %} {{image.get_dicom_info|safe}}<br /> {% endcomment %}
{% endfor %} {% endfor %}
</details> </details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p> <p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
@@ -95,6 +112,35 @@
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#set-lower-truncation-bound-button").click(() => {
dicom_element = $(".cornerstone-element").get(0);
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
$("#lower-truncation-bound-input").val(index+1);
});
$("#set-upper-truncation-bound-button").click(() => {
dicom_element = $(".cornerstone-element").get(0);
index = cornerstone.getEnabledElement(dicom_element).toolStateManager.toolState.stack.data[0].currentImageIdIndex
$("#upper-truncation-bound-input").val(index+1);
});
$("#truncate-button").click(() => {
console.log("click")
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
if (lower_bound >= upper_bound) {
alert("The lower bound must be less than the upper bound.");
return;
}
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#trucate-output")
}
})
$("#add-finding-button").click(() => { $("#add-finding-button").click(() => {
dicom_element = $(".cornerstone-element").get(0); dicom_element = $(".cornerstone-element").get(0);
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element); cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
+76 -15
View File
@@ -188,16 +188,27 @@ class SeriesImageBase(models.Model):
(larger) original file will be deleted but the model object will be persisted so that (larger) original file will be deleted but the model object will be persisted so that
the duplicate check continues to function. the duplicate check continues to function.
""" """
position = models.IntegerField(default=0) position = models.IntegerField(default=0)
upload_filename = models.CharField(max_length=255, blank=True) upload_filename = models.CharField(max_length=255, blank=True)
series = models.ForeignKey( series = models.ForeignKey(
"Series", related_name="images", on_delete=models.SET_NULL, null=True "Series", related_name="images", on_delete=models.SET_NULL, null=True
) )
dicom_tags_ohif = models.JSONField(help_text="Holds the dicom tags required for the OHIF viewer", blank=True, null=True) dicom_tags_ohif = models.JSONField(
help_text="Holds the dicom tags required for the OHIF viewer",
blank=True,
null=True,
)
image_md5_hash = models.CharField(max_length=32, null=True, blank=True) image_md5_hash = models.CharField(max_length=32, null=True, blank=True)
is_dicom = models.BooleanField(default=False) is_dicom = models.BooleanField(default=False)
removed = models.BooleanField(
default=False,
help_text="Set to true if the file this object refers to has been removed (either from series truncation or merging)",
)
class Meta: class Meta:
ordering = ["position"] ordering = ["position"]
abstract = True abstract = True
@@ -219,7 +230,6 @@ class SeriesImageBase(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Override save method to add image hash""" """Override save method to add image hash"""
if self.image: if self.image:
if not self.image_md5_hash: if not self.image_md5_hash:
image_hash, is_dicom = get_image_hash(self.image) image_hash, is_dicom = get_image_hash(self.image)
@@ -232,13 +242,19 @@ class SeriesImageBase(models.Model):
super().save(*args, **kwargs) # Call the "real" save() method. super().save(*args, **kwargs) # Call the "real" save() method.
class SeriesBase(models.Model): class SeriesBase(models.Model):
info = models.TextField( info = models.TextField(
blank=True, blank=True,
help_text="Description of stack, for admin organisation, will not be visible when taking", help_text="Description of stack, for admin organisation, will not be visible when taking",
) )
description = models.CharField(null=True, blank=True, max_length=255, help_text="Description of the series. This is usually visable to the user (as the name of the stack)") description = models.CharField(
null=True,
blank=True,
max_length=255,
help_text="Description of the series. This is usually visable to the user (as the name of the stack)",
)
open_access = models.BooleanField( open_access = models.BooleanField(
help_text="If a series should be freely available to browse", default=True help_text="If a series should be freely available to browse", default=True
@@ -247,12 +263,29 @@ class SeriesBase(models.Model):
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
modified_date = models.DateTimeField(auto_now=True) modified_date = models.DateTimeField(auto_now=True)
class SeriesModifiedChocies(models.TextChoices):
NO = "NO", "Not modified",
TR = "TR", "Truncated",
RE = "RE", "Resliced/resampled",
modified = models.CharField(max_length=2, default=SeriesModifiedChocies.NO, choices=SeriesModifiedChocies.choices)
class Meta: class Meta:
abstract = True abstract = True
def __str__(self): def __str__(self):
return f"{self.pk}:{self.description}" return f"{self.pk}:{self.description}"
def check_user_can_edit(self, user: User):
if user.is_superuser:
return True
if user in self.get_author_objects():
return True
return False
def get_author_objects(self): def get_author_objects(self):
"""Returns a comma seperated text list of authors""" """Returns a comma seperated text list of authors"""
if self.author: if self.author:
@@ -280,25 +313,47 @@ class SeriesBase(models.Model):
contrast = " {}".format(self.contrast) contrast = " {}".format(self.contrast)
return "{}{}{}".format(examination, plane, contrast) return "{}{}{}".format(examination, plane, contrast)
def get_images(self, include_removed=False):
if include_removed:
return self.images.all()
else:
return self.images.filter(removed=False)
def get_image_count(self):
return self.images.filter(removed=False).count()
def get_image_urls(self): def get_image_urls(self):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] images = [
f"{REMOTE_URL}{i.image.url}" for i in self.images.filter(removed=False)
]
return ",".join(images) return ",".join(images)
def get_image_url_array_not_json(self): def get_image_url_array_not_json(self):
return self.get_image_url_array(json_output=False) return self.get_image_url_array(json_output=False)
def get_image_url_array(self, json_output=True): def get_image_url_array_and_count(self, json_output=True):
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] return self.get_image_url_array(json_output=json_output, count=True)
def get_image_url_array(self, json_output=True, count=False):
images = [
f"{REMOTE_URL}{i.image.url}" for i in self.images.filter(removed=False)
]
if json_output: if json_output:
return json.dumps(images) if count:
return (json.dumps(images), len(images))
else:
return json.dumps(images)
else: else:
# This is a mess... # This is a mess...
return format_html('", "'.join(images)) if count:
return (format_html('", "'.join(images)), len(images))
else:
return format_html('", "'.join(images))
def get_thumbnail(self): def get_thumbnail(self):
images = self.images.all() images = self.images.filter(removed=False)
if len(images) < 1: if len(images) < 1:
return "No images", 0 return "No images", 0
@@ -343,7 +398,7 @@ class SeriesBase(models.Model):
) )
def order_by_upload_filename(self): def order_by_upload_filename(self):
images = self.images.all() images = self.images.filter(removed=False)
filenames = [] filenames = []
map = {} map = {}
@@ -362,7 +417,7 @@ class SeriesBase(models.Model):
n = n + 1 n = n + 1
def order_by_dicom(self, field="SliceLocation"): def order_by_dicom(self, field="SliceLocation"):
images = self.images.all() images = self.images.filter(removed=False)
files = [] files = []
@@ -400,7 +455,7 @@ class SeriesBase(models.Model):
n = n + 1 n = n + 1
def get_total_image_size(self): def get_total_image_size(self):
images = self.images.all() images = self.images.filter(removed=False)
size = 0 size = 0
@@ -415,7 +470,7 @@ class SeriesBase(models.Model):
# even with the same dicom... # even with the same dicom...
anonymizer = dicognito.anonymizer.Anonymizer() anonymizer = dicognito.anonymizer.Anonymizer()
for series_image in self.images.all(): for series_image in self.images.filter(removed=False):
file_path = os.path.join(settings.MEDIA_ROOT, series_image.image.name) file_path = os.path.join(settings.MEDIA_ROOT, series_image.image.name)
try: try:
@@ -1288,6 +1343,7 @@ USER_EXAM_TYPES = (
# ("CaseCollection", "user_casecollection_exams"), # ("CaseCollection", "user_casecollection_exams"),
) )
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
supervisor = models.ForeignKey( supervisor = models.ForeignKey(
@@ -1306,7 +1362,13 @@ class UserProfile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
peninsula_trainee = models.BooleanField(default=False) peninsula_trainee = models.BooleanField(default=False)
site = models.ForeignKey("Site", on_delete=models.SET_NULL, blank=True, null=True, help_text="Primary site / rotation location") site = models.ForeignKey(
"Site",
on_delete=models.SET_NULL,
blank=True,
null=True,
help_text="Primary site / rotation location",
)
def getusername(self): def getusername(self):
return self.user.username return self.user.username
@@ -1374,4 +1436,3 @@ def create_user_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs): def save_user_profile(sender, instance, **kwargs):
instance.userprofile.save() instance.userprofile.save()
@@ -0,0 +1,23 @@
# Generated by Django 4.1.4 on 2023-12-18 11:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0010_rename_created_at_longseries_created_date_and_more'),
]
operations = [
migrations.AddField(
model_name='longseries',
name='modified',
field=models.CharField(choices=[('NO', 'Not modified'), ('TR', 'Truncated'), ('RE', 'Resliced/resampled')], default='NO', max_length=2),
),
migrations.AddField(
model_name='longseriesimage',
name='removed',
field=models.BooleanField(default=False, help_text='Set to true if the file this object refers to has been removed (either from series truncation or merging)'),
),
]