add the ablitiy to truncate series on the site
This commit is contained in:
@@ -180,6 +180,26 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
|
||||
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])
|
||||
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
@@ -466,7 +466,7 @@ def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
|
||||
|
||||
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", 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.",
|
||||
)
|
||||
|
||||
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):
|
||||
try:
|
||||
with pydicom.dcmread(self.image) as d:
|
||||
@@ -563,15 +558,6 @@ class Series(SeriesBase):
|
||||
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)
|
||||
|
||||
# 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....)
|
||||
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()
|
||||
if series_n == 0:
|
||||
for tag in to_keep:
|
||||
|
||||
@@ -10,47 +10,64 @@
|
||||
This series is not associated with any cases.
|
||||
{% endif %}
|
||||
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ series.get_image_url_array }}" data-annotations=''>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details open>
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
{% endfor %}
|
||||
</details>
|
||||
<details open>
|
||||
<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>
|
||||
@@ -72,20 +89,20 @@
|
||||
<summary>
|
||||
Image details
|
||||
</summary>
|
||||
{% for image in series.images.all %}
|
||||
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
|
||||
{% 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}}
|
||||
|
||||
|
||||
{% if image.image %}
|
||||
[{{image.image.size|filesizeformat}}]
|
||||
{% endif %}
|
||||
{% if image.image %}
|
||||
[{{image.image.size|filesizeformat}}]
|
||||
{% 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 %}
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||
@@ -95,6 +112,35 @@
|
||||
|
||||
<script>
|
||||
$(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(() => {
|
||||
dicom_element = $(".cornerstone-element").get(0);
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||
|
||||
+77
-16
@@ -188,16 +188,27 @@ class SeriesImageBase(models.Model):
|
||||
(larger) original file will be deleted but the model object will be persisted so that
|
||||
the duplicate check continues to function.
|
||||
"""
|
||||
|
||||
position = models.IntegerField(default=0)
|
||||
upload_filename = models.CharField(max_length=255, blank=True)
|
||||
series = models.ForeignKey(
|
||||
"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)
|
||||
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:
|
||||
ordering = ["position"]
|
||||
abstract = True
|
||||
@@ -219,26 +230,31 @@ class SeriesImageBase(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save method to add image hash"""
|
||||
if self.image:
|
||||
|
||||
if not self.image_md5_hash:
|
||||
image_hash, is_dicom = get_image_hash(self.image)
|
||||
|
||||
self.is_dicom = is_dicom
|
||||
self.image_md5_hash = image_hash
|
||||
|
||||
|
||||
# Hack for tests
|
||||
if image_hash != "12345ABCD":
|
||||
super().save(*args, **kwargs) # Call the "real" save() method.
|
||||
|
||||
super().save(*args, **kwargs) # Call the "real" save() method.
|
||||
|
||||
|
||||
class SeriesBase(models.Model):
|
||||
info = models.TextField(
|
||||
blank=True,
|
||||
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(
|
||||
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)
|
||||
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:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
"""Returns a comma seperated text list of authors"""
|
||||
if self.author:
|
||||
@@ -280,25 +313,47 @@ class SeriesBase(models.Model):
|
||||
contrast = " {}".format(self.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):
|
||||
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)
|
||||
|
||||
def get_image_url_array_not_json(self):
|
||||
return self.get_image_url_array(json_output=False)
|
||||
|
||||
def get_image_url_array(self, json_output=True):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
def get_image_url_array_and_count(self, json_output=True):
|
||||
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:
|
||||
return json.dumps(images)
|
||||
if count:
|
||||
return (json.dumps(images), len(images))
|
||||
else:
|
||||
return json.dumps(images)
|
||||
else:
|
||||
# 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):
|
||||
images = self.images.all()
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
@@ -343,7 +398,7 @@ class SeriesBase(models.Model):
|
||||
)
|
||||
|
||||
def order_by_upload_filename(self):
|
||||
images = self.images.all()
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
filenames = []
|
||||
map = {}
|
||||
@@ -362,7 +417,7 @@ class SeriesBase(models.Model):
|
||||
n = n + 1
|
||||
|
||||
def order_by_dicom(self, field="SliceLocation"):
|
||||
images = self.images.all()
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
files = []
|
||||
|
||||
@@ -400,7 +455,7 @@ class SeriesBase(models.Model):
|
||||
n = n + 1
|
||||
|
||||
def get_total_image_size(self):
|
||||
images = self.images.all()
|
||||
images = self.images.filter(removed=False)
|
||||
|
||||
size = 0
|
||||
|
||||
@@ -415,7 +470,7 @@ class SeriesBase(models.Model):
|
||||
# even with the same dicom...
|
||||
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)
|
||||
|
||||
try:
|
||||
@@ -1288,6 +1343,7 @@ USER_EXAM_TYPES = (
|
||||
# ("CaseCollection", "user_casecollection_exams"),
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
supervisor = models.ForeignKey(
|
||||
@@ -1306,7 +1362,13 @@ class UserProfile(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
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):
|
||||
return self.user.username
|
||||
@@ -1374,4 +1436,3 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
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)'),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user