start migration to dv3d

This commit is contained in:
Ross
2025-06-09 11:39:38 +01:00
parent 503c8f854a
commit 8e92de3420
20 changed files with 4444 additions and 84 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ class StructureForm(ModelForm):
class SeriesFindingForm(ModelForm): class SeriesFindingForm(ModelForm):
class Meta: class Meta:
model = SeriesFinding model = SeriesFinding
exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index"] exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index", "annotation_json_3d", "viewer_state_3d"]
widgets = { widgets = {
"findings": autocomplete.ModelSelect2Multiple( "findings": autocomplete.ModelSelect2Multiple(
@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-06-09 10:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0069_caseprior_prior_visibility'),
]
operations = [
migrations.AddField(
model_name='seriesfinding',
name='annotation_json_3d',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='seriesfinding',
name='viewer_state_3d',
field=models.JSONField(default=dict),
),
]
+2
View File
@@ -628,6 +628,8 @@ class SeriesFinding(FindingBase):
def __str__(self) -> str: def __str__(self) -> str:
findings = self.findings.all().values_list("name") findings = self.findings.all().values_list("name")
if self.series is None:
return f"SeriesFinding: {findings}/{self.description} (no series)"
return f"SeriesFinding: {self.series.id}/{findings}/{self.description}" return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
@reversion.register @reversion.register
+42 -8
View File
@@ -3,8 +3,8 @@
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div> <div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
<div>Description: {{series.description}}</div> <div>Description: {{series.description}}</div>
Associated case: Associated case:
{% for case in series.case.all %} {% for case in series.case.all %}
<span id="case-link-{{ case.pk }}" class="case-item"> <span id="case-link-{{ case.pk }}" class="case-item">
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a> <a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% if can_edit %} {% if can_edit %}
@@ -17,7 +17,7 @@
</button> </button>
{% endif %} {% endif %}
</span> </span>
{% empty %} {% empty %}
This series is not associated with any cases. This series is not associated with any cases.
{% if can_edit %} {% if can_edit %}
<details> <details>
@@ -37,12 +37,18 @@
<div id="add-series-to-case-result"></div> <div id="add-series-to-case-result"></div>
</details> </details>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% with image_url_array_and_count=series.get_image_url_array_and_count %} {% 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 id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> </div>
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=true
></div>
{% if can_edit %} {% if can_edit %}
{% if editing_finding < 1 %} {% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button> <button id="add-finding-button">Add finding</button>
@@ -80,6 +86,11 @@
<div class="finding-box"> <div class="finding-box">
<button id="finding-{{finding.pk}}" class="view-finding-button" data-annotationjson={{finding.annotation_json}} <button id="finding-{{finding.pk}}" class="view-finding-button" data-annotationjson={{finding.annotation_json}}
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>Click to view</button> data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>Click to view</button>
{% if finding.viewer_state_3d %}
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}'>Click to view 3D</button>
{% endif %}
<span class="view-finding-details"> <span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br /> 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 /> Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
@@ -95,6 +106,11 @@
<pre>{{finding.viewport_json}}</pre> <pre>{{finding.viewport_json}}</pre>
<h4>Image ID</h4> <h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre> <pre>{{finding.current_image_id_index}}</pre>
<h4>3D Annotation JSON</h4>
<pre>{{finding.annotation_json_3d}}</pre>
<h4>Viewer State</h4>
<pre>{{finding.viewer_state_3d}}</pre>
</div> </div>
{% endif %} {% endif %}
</span> </span>
@@ -124,8 +140,8 @@
<div>Author: {{ series.get_author_display }}</div> <div>Author: {{ series.get_author_display }}</div>
{% if can_edit %} {% if can_edit %}
<details> <details>
<summary>Series info</summary> <summary>Series info</summary>
<div> <div>
<a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}" <a href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}"
@@ -171,8 +187,8 @@
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p> <p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
<p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p> <p><a href="{% url 'atlas:series_download' pk=series.pk %}">Download images</a></p>
</details> </details>
<span id="diff-controls">show <span id="diff-show-all" _="on click <span id="diff-controls">show <span id="diff-show-all" _="on click
log '1' log '1'
for row in .diff.rows for row in .diff.rows
show row show row
@@ -284,6 +300,17 @@
cornerstone.resize(dicom_element, true); cornerstone.resize(dicom_element, true);
}); });
$(".view-finding-button-3d").each((n, el) => {
$(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0);
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
importAnnotations_root(annotationjson3d);
importViewerState_root(viewer_state_json);
$(el).addClass("active");
});
});
$(".view-finding-button").each((n, el) => { $(".view-finding-button").each((n, el) => {
$(el).click((e) => { $(el).click((e) => {
@@ -293,6 +320,11 @@
current_image_id_index = e.currentTarget.dataset.currentimageid; current_image_id_index = e.currentTarget.dataset.currentimageid;
console.log(e.currentTarget, current_image_id_index) console.log(e.currentTarget, current_image_id_index)
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index); loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index);
importLegacyViewport_root(viewport)
importLegacyAnnotations_root(annotationjson);
jumpToSliceByImageId_root(0, current_image_id_index);
console.log("Loaded annotation and viewport on element", dicom_element, annotationjson, viewport, current_image_id_index);
$(el).addClass("active"); $(el).addClass("active");
@@ -368,6 +400,8 @@
annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager
.saveToolState()), .saveToolState()),
viewport_json: JSON.stringify(c.viewport), viewport_json: JSON.stringify(c.viewport),
annotation_json_3d: window.exportAnnotations_root(),
viewer_state_3d: window.exportViewerState_root(),
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected") findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
.map((i, el) => { .map((i, el) => {
return $(el).val() return $(el).val()
+74
View File
@@ -0,0 +1,74 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> {% endcomment %}
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
></div>
{% endwith %}
<script type="module" src="{% static 'dv3d/index-DCNn6AQH.js' %}" defer="defer" type="module"></script>
<input type="file" id="dicomInput" multiple />
<button onclick="sendToViewer()">Send to Viewer</button>
<button onclick="testLoadWadouriStack()">Test Load wadouri Stack</button>
<script>
function sendToViewer() {
const files = document.getElementById('dicomInput').files;
if (window.loadDicomStackFromFiles) {
window.loadDicomStackFromFiles(files);
} else {
alert("Viewer API not ready");
}
}
function testLoadWadouriStack() {
const urls = [
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_757_XQQyovo.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_766_8fWI0Qc.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_774_c8IpxUu.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_758_6WKI7ws.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_767_TduVOb8.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_775_7zWneQ0.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_759_BtKzgS5.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_768_RdvtM8K.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_776_LhVY3Ty.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_760_a8CiR4J.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_769_YHyJ1l6.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_777_8IFYJmL.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_761_vyvO1EN.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_770_vQwi4LW.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_778_BwGAMOR.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_762_DhSgQsm.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_771_xifitCl.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_779_LyA1zYn.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_763_aVjff3H.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_772_cDQZtxQ.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_780_qaZqsqg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_764_dDoeldL.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_773_93Hvedg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_781_bEx9Owg.dcm",
"https://www.penracourses.org.uk/media/atlas/dicom/IMG_765_qmd5mYJ.dcm"
];
const wadouriList = urls.map(url => "wadouri:" + url);
if (window.loadDicomStackFromWadouriList) {
window.loadDicomStackFromWadouriList(wadouriList);
} else {
alert("Viewer API not ready");
}
}
</script>
</body>
</html>
@@ -0,0 +1,47 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>SeriesFinding Migration Status</h2>
<table class="table table-bordered table-sm">
<thead>
<tr>
<th>ID</th>
<th>Description</th>
<th>Series</th>
<th>annotation_json_3d</th>
<th>viewer_state_3d</th>
</tr>
</thead>
<tbody>
{% for f in findings_status %}
<tr>
<td>{{ f.id }}</td>
<td>{{ f.str }}</td>
<td>
{% if f.series %}
<a href="{% url 'atlas:series_detail' f.series.pk %}">{{ f.series }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
<td>
{% if f.annotation_json_3d %}
<span class="badge bg-success">Populated</span>
{% else %}
<span class="badge bg-danger">Empty</span>
{% endif %}
</td>
<td>
{% if f.viewer_state_3d %}
<span class="badge bg-success">Populated</span>
{% else %}
<span class="badge bg-danger">Empty</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}
+3 -1
View File
@@ -266,6 +266,8 @@ urlpatterns = [
), ),
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'), path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'), path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"), path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
path("question_schema/", views.QuestionSchemaView.as_view(), name="question_schema_overview"), path("question_schema/", views.QuestionSchemaView.as_view(), name="question_schema_overview"),
path("question_schema/<int:pk>", views.question_schema_detail, name="question_schema_detail"), path("question_schema/<int:pk>", views.question_schema_detail, name="question_schema_detail"),
@@ -297,7 +299,6 @@ urlpatterns = [
), ),
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"), path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
path("finding/", views.FindingView.as_view(), name="finding_view"), path("finding/", views.FindingView.as_view(), name="finding_view"),
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
path("finding/<int:pk>", views.finding_detail, name="finding_detail"), path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
path( path(
"finding/<int:pk>/delete", views.FindingDelete.as_view(), name="finding_delete" "finding/<int:pk>/delete", views.FindingDelete.as_view(), name="finding_delete"
@@ -323,6 +324,7 @@ urlpatterns = [
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"), path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
# TODO: case context series viewing (so that we can view series in the context of a case) # TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"), path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"), path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"), path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
path("series/", views.SeriesView.as_view(), name="series_view"), path("series/", views.SeriesView.as_view(), name="series_view"),
+36
View File
@@ -231,6 +231,19 @@ def series_thumbnail(request, pk, fail_loudly=False):
return HttpResponse(thumbnail) return HttpResponse(thumbnail)
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_viewer(request, pk, finding_pk=None):
series = get_object_or_404(Series, pk=pk)
context = {
"series": series,
}
return render(
request,
"atlas/series_viewer_test.html",
context,
)
@login_required @login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
@@ -1440,6 +1453,8 @@ def create_series_findings(request):
annotation_json = request.POST.get("annotation_json") annotation_json = request.POST.get("annotation_json")
viewport_json = request.POST.get("viewport_json") viewport_json = request.POST.get("viewport_json")
current_image_id_index = request.POST.get("current_image_id_index") current_image_id_index = request.POST.get("current_image_id_index")
annotation_json_3d = request.POST.get("annotation_json_3d")
viewer_state_3d = request.POST.get("viewer_state_3d")
series = Series.objects.get(pk=series_id) series = Series.objects.get(pk=series_id)
findings = Finding.objects.filter(pk__in=findings_ids) findings = Finding.objects.filter(pk__in=findings_ids)
@@ -1464,6 +1479,8 @@ def create_series_findings(request):
sf.structures.set(structures) sf.structures.set(structures)
sf.conditions.set(conditions) sf.conditions.set(conditions)
sf.current_image_id_index = current_image_id_index sf.current_image_id_index = current_image_id_index
sf.annotation_json_3d = annotation_json_3d
sf.viewer_state_3d = viewer_state_3d
sf.save() sf.save()
return JsonResponse({"success": True}) return JsonResponse({"success": True})
@@ -3146,3 +3163,22 @@ def series_finding_related(request, series_id):
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
findings = series.get_related_findings() findings = series.get_related_findings()
return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings}) return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings})
def seriesfinding_migration_status(request):
"""
View to list all SeriesFinding objects and their migration status.
Shows whether annotation_json_3d and viewer_state_3d are populated.
"""
findings = SeriesFinding.objects.all().select_related()
findings_status = []
for finding in findings:
findings_status.append({
"id": finding.id,
"str": str(finding),
"annotation_json_3d": bool(finding.annotation_json_3d),
"viewer_state_3d": bool(finding.viewer_state_3d),
"series": finding.series
})
return render(request, "atlas/seriesfinding_migration_status.html", {
"findings_status": findings_status
})
+3
View File
@@ -1998,6 +1998,9 @@ class FindingBase(models.Model):
# This has been migrated to install the image id (not index) # This has been migrated to install the image id (not index)
current_image_id_index = models.TextField(null=True, blank=True) current_image_id_index = models.TextField(null=True, blank=True)
annotation_json_3d = models.JSONField(default=dict)
viewer_state_3d = models.JSONField(default=dict)
# TODO add tags # TODO add tags
# tags = models.ManyToManyField(Tag, blank=True) # tags = models.ManyToManyField(Tag, blank=True)
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
html,body,#root{width:100vw;height:100vh;margin:0;padding:0;overflow:hidden;box-sizing:border-box}.dicom-viewer-root,#root{width:100vw;height:100vh;min-height:0;min-width:0;position:relative}.dicom-viewer-test-root{box-sizing:border-box;color:gray;display:block;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:16px;font-weight:400;height:500px;line-height:24px;max-height:500px;overflow-x:auto;overflow-y:auto;text-align:start;text-size-adjust:100%;unicode-bidi:isolate;width:1296px;-webkit-tap-highlight-color:rgba(0,0,0,0)}.cornerstone-viewport,.cornerstone-canvas{cursor:inherit!important}
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-06-09 10:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shorts', '0008_useranswer_candidate_feedback_answermarks'),
]
operations = [
migrations.AddField(
model_name='questionfinding',
name='annotation_json_3d',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='questionfinding',
name='viewer_state_3d',
field=models.JSONField(default=dict),
),
]
+2
View File
@@ -70,6 +70,8 @@
<script src="{% static 'js/cornerstone/cornerstoneWADOImageLoader.min.js' %}"></script> <script src="{% static 'js/cornerstone/cornerstoneWADOImageLoader.min.js' %}"></script>
<script src="{% static 'js/cornerstone/cornerstone-base64-image-loader.umd.js' %}"></script> <script src="{% static 'js/cornerstone/cornerstone-base64-image-loader.umd.js' %}"></script>
<script src="{% static 'js/anatomy.js' %}" defer="defer" type="module"></script> <script src="{% static 'js/anatomy.js' %}" defer="defer" type="module"></script>
<script src="{% static 'dv3d/index-DwrZLsZY.js' %}" type="module" defer="defer" type="module"></script>
{% block js %} {% block js %}
{% endblock %} {% endblock %}
<link rel="stylesheet" href="{% static 'css/anatomy.css' %}"> <link rel="stylesheet" href="{% static 'css/anatomy.css' %}">