diff --git a/exact/exact/administration/templates/administration/product_management.html b/exact/exact/administration/templates/administration/product_management.html new file mode 100644 index 00000000..8d440dc5 --- /dev/null +++ b/exact/exact/administration/templates/administration/product_management.html @@ -0,0 +1,564 @@ +{% extends 'base/base.html' %} +{% load widget_tweaks %} + +{% block bodyblock %} + + +
+ {% for message in messages %} + + {% endfor %} + +
+ +
+
+
Products & Types
+ + Product +
+ +
+ {% for team in teams %} + {% with is_active_team=selected_product.team_id|stringformat:"s" %} +
+
+
+ +
+
+
+
+ {% for product in all_products %} + {% if product.team_id == team.id %} + +
    + {% for at in product.annotationtype_set.all %} +
  • + + + {{ at.name }} + + {{ at.vector_type_display }} + {% if not at.active %}off{% endif %} +
  • + {% endfor %} +
+ {% endif %} + {% endfor %} +
+
+
+ {% endwith %} + {% empty %} +
No teams found.
+ {% endfor %} +
+
+ + +
+
+ + + {% if panel == 'create_product' %} +

Create New Product

+
+ {% csrf_token %} +
+ + {% render_field product_create_form.name class+='form-control' %} +
+
+ + {% render_field product_create_form.description class+='form-control' %} +
+
+ + {% render_field product_create_form.team class+='form-control' %} +
+
+ + +
+
+ + +
+
+ + + {% elif panel == 'edit_product' %} +

Edit Product: {{ selected_product.name }}

+
+ {% csrf_token %} +
+ + {% render_field product_edit_form.name class+='form-control' %} +
+
+ + {% render_field product_edit_form.description class+='form-control' %} +
+
+ + {% render_field product_edit_form.team class+='form-control' %} +
+
+ + +
+
+ + + {% elif panel == 'create_annotation_type' %} +

New Annotation Type{% if new_type_for %} for {{ new_type_for.name }}{% endif %}

+
+ {% csrf_token %} +
+
+ + {% render_field at_create_form.name class+='form-control' %} +
+
+ + {% render_field at_create_form.sort_order class+='form-control' style='width:80px' %} +
+
+
+
+ + +
+
+ + {% render_field at_create_form.product class+='form-control' %} +
+
+
+
+ + +
+
+ + {% render_field at_create_form.node_count class+='form-control' %} +
+
+
+
+ + {% render_field at_create_form.default_width class+='form-control' %} +
+
+ + {% render_field at_create_form.default_height class+='form-control' %} +
+
+
+
+ {% render_field at_create_form.active class+='form-check-input' %} + +
+
+ {% render_field at_create_form.closed class+='form-check-input' %} + +
+
+ {% render_field at_create_form.area_hit_test class+='form-check-input' %} + +
+
+
+
+ {% render_field at_create_form.multi_frame class+='form-check-input' %} + +
+
+ {% render_field at_create_form.enable_concealed class+='form-check-input' %} + +
+
+ {% render_field at_create_form.enable_blurred class+='form-check-input' %} + +
+
+
+ Cancel + +
+
+ + + {% elif panel == 'edit_annotation_type' %} +

Edit Annotation Type: {{ selected_at.name }}

+
+ {% csrf_token %} +
+
+ + {% render_field at_edit_form.name class+='form-control' %} +
+
+ + {% render_field at_edit_form.sort_order class+='form-control' style='width:80px' %} +
+
+
+
+ + +
+
+ + {% render_field at_edit_form.product class+='form-control' %} +
+
+
+
+ + +
+
+ + {% render_field at_edit_form.default_width class+='form-control' %} +
+
+ + {% render_field at_edit_form.default_height class+='form-control' %} +
+
+
+
+ {% render_field at_edit_form.active class+='form-check-input' %} + +
+
+ {% render_field at_edit_form.closed class+='form-check-input' %} + +
+
+ {% render_field at_edit_form.area_hit_test class+='form-check-input' %} + +
+
+
+
+ {% render_field at_edit_form.multi_frame class+='form-check-input' %} + +
+
+ {% render_field at_edit_form.enable_concealed class+='form-check-input' %} + +
+
+ {% render_field at_edit_form.enable_blurred class+='form-check-input' %} + +
+
+ {% if selected_at.image_file %} +
+
+ +
+ {% endif %} +
+ + +
+
+ + Delete + + +
+
+ {% endif %} + +
+
+
+
+ + +{% endblock %} diff --git a/exact/exact/administration/urls.py b/exact/exact/administration/urls.py index ce353d24..0b8c3c74 100644 --- a/exact/exact/administration/urls.py +++ b/exact/exact/administration/urls.py @@ -14,6 +14,7 @@ #endregion + re_path(r'^manage/$', views.product_management, name='product_management'), re_path(r'^products/list/$', views.products, name='products'), re_path(r'^products/(\d+)/$', views.product, name='product'), re_path(r'^products/create/$', views.create_product, name='create_product'), diff --git a/exact/exact/administration/views.py b/exact/exact/administration/views.py index c94c2f23..14c1c60b 100644 --- a/exact/exact/administration/views.py +++ b/exact/exact/administration/views.py @@ -68,11 +68,62 @@ def logs(request): }) def products(request): - teams = Team.objects.filter(members=request.user) - return render(request, 'administration/product.html', { - 'products': Product.objects.filter(team__in=request.user.team_set.all()).order_by('team_id'), - 'create_form': ProductCreationForm, - 'teams': teams + return redirect(reverse('administration:product_management')) + + +@login_required +def product_management(request): + teams = Team.objects.filter(members=request.user).order_by('name') + products_qs = (Product.objects + .filter(team__in=teams) + .order_by('team__name', 'name') + .select_related('team') + .prefetch_related('annotationtype_set')) + + selected_product = None + selected_at = None + new_type_for = None + panel = 'create_product' + at_edit_form = None + at_create_form = None + product_edit_form = None + + product_create_form = ProductCreationForm() + product_create_form.fields['team'].queryset = teams + + atid = request.GET.get('annotation_type') + ntf = request.GET.get('new_type_for') + pid = request.GET.get('product') + + if atid: + selected_at = get_object_or_404(AnnotationType, id=atid) + selected_product = selected_at.product + panel = 'edit_annotation_type' + at_edit_form = AnnotationTypeEditForm(instance=selected_at) + at_edit_form.fields['product'].queryset = products_qs + elif ntf: + new_type_for = get_object_or_404(Product, id=ntf) + selected_product = new_type_for + panel = 'create_annotation_type' + at_create_form = AnnotationTypeCreationForm(initial={'product': new_type_for.id}) + at_create_form.fields['product'].queryset = products_qs + elif pid: + selected_product = get_object_or_404(Product, id=pid) + panel = 'edit_product' + product_edit_form = ProductEditForm(instance=selected_product) + product_edit_form.fields['team'].queryset = teams + + return render(request, 'administration/product_management.html', { + 'teams': teams, + 'all_products': products_qs, + 'selected_product': selected_product, + 'selected_at': selected_at, + 'panel': panel, + 'new_type_for': new_type_for, + 'at_edit_form': at_edit_form, + 'at_create_form': at_create_form, + 'product_edit_form': product_edit_form, + 'product_create_form': product_create_form, }) @@ -451,13 +502,11 @@ def create_product(request): annotationType.save() - return redirect(reverse('administration:product', args=(product.id,))) + return redirect(reverse('administration:product_management') + f'?product={product.id}') else: messages.error(request, _('The name team combination is already in use by an product.')) - - - return redirect(reverse('administration:products')) + return redirect(reverse('administration:product_management')) def edit_product(request, product_id): @@ -475,7 +524,7 @@ def edit_product(request, product_id): selected_product.save() messages.success(request, _('The product was edited successfully.')) - return redirect(reverse('administration:product', args=(product_id, ))) + return redirect(reverse('administration:product_management') + f'?product={product_id}') def annotation_types(request): @@ -768,15 +817,16 @@ def create_annotation_type(request): type = form.save() messages.success(request, _('The annotation type was created successfully.')) - return redirect(reverse('administration:annotation_type', args=(type.id,))) + return redirect(reverse('administration:product_management') + f'?annotation_type={type.id}') else: messages.error(request, _('The name is already in use by an annotation type.')) - - return redirect(reverse('administration:annotation_types')) + + return redirect(reverse('administration:product_management')) def delete_annotation_type(request, annotation_type_id): selected_annotation_type = get_object_or_404(AnnotationType, id=annotation_type_id) + product_id = selected_annotation_type.product_id if request.method == 'GET': if request.user.has_perm('annotations.delete_annotationtype'): @@ -785,13 +835,12 @@ def delete_annotation_type(request, annotation_type_id): else: messages.success(request, _('The annotation type was deleted successfully.')) selected_annotation_type.delete() - return redirect(reverse('administration:annotation_types')) - + base = reverse('administration:product_management') + return redirect(f'{base}?product={product_id}' if product_id else base) else: messages.error(request, _('Missing rights to delete annotation type.')) - - return redirect(reverse('administration:annotation_type', args=(annotation_type_id, ))) + return redirect(reverse('administration:product_management') + f'?annotation_type={annotation_type_id}') def edit_annotation_type(request, annotation_type_id): @@ -823,7 +872,7 @@ def edit_annotation_type(request, annotation_type_id): selected_annotation_type.save() messages.success(request, _('The annotation type was edited successfully.')) - return redirect(reverse('administration:annotation_type', args=(annotation_type_id, ))) + return redirect(reverse('administration:product_management') + f'?annotation_type={annotation_type_id}') @staff_member_required diff --git a/exact/exact/annotations/models.py b/exact/exact/annotations/models.py index c508148e..d46ba036 100644 --- a/exact/exact/annotations/models.py +++ b/exact/exact/annotations/models.py @@ -498,6 +498,10 @@ def get_vector_type_name(vector_type): if vector_type is AnnotationType.VECTOR_TYPE.SEGMENTATION: return 'Segmentation' + @property + def vector_type_display(self): + return self.get_vector_type_name(self.vector_type) or str(self.vector_type) + def validate_vector(self, vector: Union[dict, None]) -> bool: """ Validate a vector. Returns whether the vector is valid. diff --git a/exact/exact/annotations/static/annotations/css/annotator-dark.css b/exact/exact/annotations/static/annotations/css/annotator-dark.css new file mode 100644 index 00000000..b47b1e72 --- /dev/null +++ b/exact/exact/annotations/static/annotations/css/annotator-dark.css @@ -0,0 +1,575 @@ +/* ───────────────────────────────────────────────────────────── + Dark annotator – icon rail + full-width viewer + + floating annotation-type panel (bottom-left) + ───────────────────────────────────────────────────────────── */ + +/* ── Tokens ── */ +:root { + --ad-bg: #111114; + --ad-surf: #1c1c22; + --ad-surf2: #26262e; + --ad-border: #36363f; + --ad-text: #d1d1d8; + --ad-muted: #6b6b78; + --ad-accent: #5c6af6; + --ad-acc2: #818cf8; + --ad-success: #4ade80; + --ad-danger: #f87171; + --ad-rail: 48px; + --ad-drawer: 290px; + --ad-hdr: 60px; + --ad-glass: rgba(14,14,20,0.88); + --ad-glass-b: rgba(255,255,255,.07); +} + +/* ── Shell ── */ +.annotator-shell { + position: fixed; + top: var(--ad-hdr); left: 0; right: 0; bottom: 0; + display: flex; + background: var(--ad-bg); + overflow: hidden; + color: var(--ad-text); + font-size: .84rem; +} + +/* ─────────────────────── ICON RAIL ──────────────────────── */ +.anno-rail { + flex: 0 0 var(--ad-rail); + background: var(--ad-surf); + border-right: 1px solid var(--ad-border); + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + gap: 2px; + z-index: 100; +} + +.rail-btn { + width: 36px; height: 36px; + background: transparent; + border: none; outline: none; + border-radius: 8px; + color: var(--ad-muted); + font-size: 14px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + transition: background .14s, color .14s; +} +.rail-btn:hover { background: var(--ad-surf2); color: var(--ad-text); } +.rail-btn.rail-active { background: var(--ad-accent); color: #fff; } + +.rail-sep { width: 26px; height: 1px; background: var(--ad-border); margin: 4px 0; } +.rail-spacer { flex: 1; } + +/* ─────────────────────── VIEWER ──────────────────────── */ +.anno-viewer { + flex: 1 1 0; + position: relative; /* anchor for absolute children */ + overflow: hidden; + background: var(--ad-bg); + display: flex; + flex-direction: column; + padding: 4px 6px; + margin-left: 0; + transition: margin-left .22s cubic-bezier(.25,.8,.25,1); +} +.annotator-shell.shell-drawer-open .anno-viewer { + margin-left: var(--ad-drawer); +} + +/* Viewer top controls */ +.anno-viewer-controls { + flex: 0 0 auto; + color: var(--ad-muted); + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px 6px 16px; +} +.anno-viewer-controls > div { display: contents; } +/* Slider + label wrapper */ +.vc-slider-wrap { + display: flex; + align-items: center; + gap: 14px; + flex: 1 1 0; + min-width: 100px; + max-width: 260px; +} +.vc-slider-wrap #zoomSlider, +.vc-slider-wrap #overlaySlider { flex: 1 1 0; min-width: 0; } +.vc-label { + font-size: .68rem; + color: var(--ad-muted); + white-space: nowrap; + flex: 0 0 auto; +} +.vc-value { + font-size: .68rem; + color: var(--ad-text); + white-space: nowrap; + min-width: 30px; + text-align: right; + flex: 0 0 auto; +} +#registration_selector, #registrationField { margin-left: auto; } + +/* OSD canvas area */ +.anno-osd-area { + flex: 1 1 0; + position: relative; +} +.seadragon-viewer, +#openseadragon1, +#openseadragon_background { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Frame slider row */ +#frameSliderDiv { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} + +/* ── Bootstrap Slider: dark compact theme ── */ +.slider.slider-horizontal { + width: 100%; + height: 28px; +} +.slider-track { + height: 3px !important; + background: var(--ad-border) !important; + box-shadow: none !important; + border-radius: 2px !important; + margin-top: -1px !important; +} +.slider-selection { + background: var(--ad-accent) !important; + box-shadow: none !important; + border-radius: 2px !important; +} +.slider-track-low, .slider-track-high { background: transparent !important; } +.slider-handle { + width: 13px !important; + height: 13px !important; + background: var(--ad-acc2) !important; + border: none !important; + box-shadow: 0 0 0 3px rgba(92,106,246,.25), 0 2px 5px rgba(0,0,0,.5) !important; + margin-top: -5px !important; + top: 50% !important; +} +.slider-handle:hover, .slider-handle:focus { + background: #fff !important; + box-shadow: 0 0 0 4px rgba(92,106,246,.35), 0 2px 5px rgba(0,0,0,.5) !important; +} +/* Hide tick marks; keep snapping */ +.slider-tick { background: transparent !important; box-shadow: none !important; } +.slider-tick.in-selection { background: transparent !important; } +/* Hide tick label row entirely (overlaySlider no longer uses labels, others keep theirs) */ +.slider-tick-label-container { display: none !important; } + +/* ── Float-panel range inputs ── */ +.fp-adv-sliders input[type="range"], +#frameSliderDiv input[type="range"], +.custom-range { + -webkit-appearance: none; + appearance: none; + height: 3px; + background: var(--ad-border); + border-radius: 2px; + outline: none; + cursor: pointer; + margin: 6px 0; +} +.fp-adv-sliders input[type="range"]::-webkit-slider-thumb, +#frameSliderDiv input[type="range"]::-webkit-slider-thumb, +.custom-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--ad-acc2); + box-shadow: 0 0 0 3px rgba(92,106,246,.25); + cursor: pointer; + transition: background .12s, box-shadow .12s; +} +.fp-adv-sliders input[type="range"]::-webkit-slider-thumb:hover, +.custom-range::-webkit-slider-thumb:hover { + background: #fff; + box-shadow: 0 0 0 4px rgba(92,106,246,.35); +} +.fp-adv-sliders input[type="range"]::-moz-range-thumb, +.custom-range::-moz-range-thumb { + width: 13px; height: 13px; + border-radius: 50%; + background: var(--ad-acc2); + border: none; + box-shadow: 0 0 0 3px rgba(92,106,246,.25); + cursor: pointer; +} +.fp-adv-sliders input[type="range"]::-moz-range-track, +.custom-range::-moz-range-track { + height: 3px; + background: var(--ad-border); + border-radius: 2px; +} + +/* Misc viewer decorations */ +#registration_selector { + background: var(--ad-surf2); border-color: var(--ad-border); + color: var(--ad-text); font-size: .78rem; +} +#registrationField { color: var(--ad-muted); font-size: .75rem; } +#seg-palette { border-radius: 8px !important; border: 1px solid var(--ad-border) !important; } +#mprLayout { background: #1a1a1f !important; } + +/* ─────────────────────── FLOATING ANNOTATION PANEL ──────────────────────── */ +/* + Always visible, positioned at bottom-left of the viewer. + Glass / frosted style so the slide is visible behind it. +*/ +.anno-float-panel { + position: fixed; + bottom: 80px; + left: calc(var(--ad-rail) + 12px); + width: 265px; + max-height: 56vh; + background: rgba(70,70,70,0.96); /* opaque — no backdrop-filter over canvas */ + border: 1px solid var(--ad-glass-b); + border-radius: 10px; + z-index: 200; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,.65); + overflow: hidden; + user-select: none; + transition: left .22s cubic-bezier(.25,.8,.25,1); +} +.annotator-shell.shell-drawer-open .anno-float-panel { + left: calc(var(--ad-rail) + var(--ad-drawer) + 12px); +} + +/* ── Float panel header (image name + collapse toggle) ── */ +.fp-header { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; + padding: 7px 10px 6px; + border-bottom: 1px solid var(--ad-glass-b); + cursor: move; +} +.fp-header-name { + flex: 1; + font-size: .76rem; + font-weight: 600; + color: var(--ad-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fp-toggle { + background: transparent; border: none; padding: 0 2px; + color: var(--ad-muted); font-size: 11px; cursor: pointer; + line-height: 1; transition: color .12s; +} +.fp-toggle:hover { color: var(--ad-text); } + +/* ── Float panel body (statistics table) ── */ +.fp-body { + flex: 0 1 auto; + min-height: 60px; + max-height: 38vh; + overflow-y: auto; + overflow-x: hidden; +} +.fp-body::-webkit-scrollbar { width: 3px; } +.fp-body::-webkit-scrollbar-thumb { background: var(--ad-border); border-radius: 2px; } + +/* Statistics table inside glass panel */ +#statistics_table { + font-size: .78rem; + margin: 0; + width: 100%; +} +#statistics_table thead th { + background: rgba(255,255,255,.04); + color: var(--ad-muted); + font-size: .65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + border: none; + border-bottom: 1px solid var(--ad-glass-b); + padding: 5px 5px; + position: sticky; top: 0; z-index: 1; +} +#statistics_table tbody tr { + border-bottom: 1px solid rgba(255,255,255,.04); + cursor: pointer; + transition: background .1s; +} +#statistics_table tbody tr:hover { background: rgba(255,255,255,.06); } +#statistics_table tbody tr.table-active { background: rgba(92,106,246,.2); } +#statistics_table td { + color: var(--ad-text); + border: none; + padding: 4px 5px; + vertical-align: middle; +} +#statistics_table input[type="checkbox"] { cursor: pointer; accent-color: var(--ad-accent); } +#statistics_table .badge-secondary { + background: rgba(255,255,255,.08); color: var(--ad-muted); font-size: .6rem; +} + +/* Image-level annotation types (global) */ +.panel-global-section { border-top: 1px solid var(--ad-glass-b); } +.panel-global-heading { + padding: 5px 8px; + font-size: .64rem; font-weight: 700; + text-transform: uppercase; letter-spacing: .05em; + color: var(--ad-muted); + background: rgba(255,255,255,.03); +} +.panel-global-row { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid rgba(255,255,255,.04); + font-size: .76rem; +} +.panel-global-row label { flex: 1; color: var(--ad-text); margin: 0; } +.panel-global-row .key-badge { + font-size: .62rem; color: var(--ad-muted); + border: 1px solid var(--ad-border); border-radius: 3px; + padding: 0 3px; line-height: 1.7; white-space: nowrap; +} + +/* ── Float panel footer ── */ +.fp-footer { + flex: 0 0 auto; + border-top: 1px solid var(--ad-glass-b); + padding: 6px 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.fp-footer-total { + font-size: .7rem; color: var(--ad-muted); +} +.fp-footer-total span { color: var(--ad-text); font-weight: 600; } + +#annotation_type_id { + background: rgba(255,255,255,.06) !important; + border: 1px solid var(--ad-glass-b) !important; + color: var(--ad-text) !important; + font-size: .76rem !important; + border-radius: 6px; + width: 100%; +} +#annotation_type_id:focus { + outline: none; + border-color: var(--ad-accent) !important; + box-shadow: 0 0 0 2px rgba(92,106,246,.25) !important; +} + +/* Advanced sliders */ +.fp-adv-sliders { padding-top: 4px; border-top: 1px solid var(--ad-glass-b); } +.fp-adv-sliders label { font-size: .7rem; color: var(--ad-muted); margin: 0; } +.fp-adv-sliders input[type="range"] { width: 100%; } + +/* Collapsed state – only header visible */ +.anno-float-panel.fp-collapsed .fp-body, +.anno-float-panel.fp-collapsed .fp-footer { display: none; } +.anno-float-panel.fp-collapsed .fp-toggle .fa-chevron-down { display: none; } +.anno-float-panel:not(.fp-collapsed) .fp-toggle .fa-chevron-up { display: none; } + +/* ─────────────────────── FLOATING DRAWERS ──────────────────────── */ +.anno-drawer { + position: absolute; + left: var(--ad-rail); + top: 0; bottom: 0; + width: var(--ad-drawer); + background: var(--ad-surf); + border-right: 1px solid var(--ad-border); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 50; + transform: translateX(calc(-1 * var(--ad-drawer) - 2px)); + transition: transform .22s cubic-bezier(.25,.8,.25,1); + box-shadow: 4px 0 20px rgba(0,0,0,.5); +} +.anno-drawer.drawer-open { transform: translateX(0); } + +/* Backdrop — transparent click-to-close shield; sits over pushed viewer only */ +.anno-backdrop { + display: none; + position: absolute; + top: 0; bottom: 0; right: 0; + left: calc(var(--ad-rail) + var(--ad-drawer)); + background: transparent; + z-index: 40; + transition: left .22s cubic-bezier(.25,.8,.25,1); +} +.anno-backdrop.active { display: block; } + +/* Shared drawer anatomy */ +.drawer-title { + flex: 0 0 auto; + padding: 9px 12px; + font-size: .68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: .07em; + color: var(--ad-muted); + border-bottom: 1px solid var(--ad-border); +} +.drawer-title a { color: var(--ad-text); text-decoration: none; font-size: .82rem; } +.drawer-title a:hover { color: #fff; } + +.drawer-body { + flex: 1 1 0; + overflow-y: auto; overflow-x: hidden; +} +.drawer-body::-webkit-scrollbar { width: 4px; } +.drawer-body::-webkit-scrollbar-thumb { background: var(--ad-border); } + +.drawer-footer { + flex: 0 0 auto; + border-top: 1px solid var(--ad-border); + padding: 8px 10px; +} + +/* Image list — override styles_v2.css max-height so the drawer-body handles scrolling */ +#drawerImages #image_list { + max-height: none !important; + overflow-y: visible !important; +} + +.drawer-img-filter { padding: 8px 10px; border-bottom: 1px solid var(--ad-border); } +.drawer-img-filter .form-control, +.drawer-img-filter select { + background: var(--ad-surf2) !important; border-color: var(--ad-border) !important; + color: var(--ad-text) !important; font-size: .78rem !important; +} +.drawer-img-filter .btn-primary { background: var(--ad-accent); border-color: var(--ad-accent); padding: 3px 8px; } + +/* ── Drag-to-upload overlay (covers entire image-list drawer) ── */ +.drawer-drop-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(136, 136, 136, 0.5); + border: 3px dashed rgba(255, 255, 255, 0.7); + border-radius: 4px; + z-index: 200; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + pointer-events: none; +} +.drawer-drop-overlay.visible { display: flex; } +.drawer-drop-overlay img { width: 56px; height: 56px; opacity: 0.9; } +.drawer-drop-overlay span { + font-size: 0.82rem; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); +} +.drawer-upload-status { + flex: 0 0 auto; + padding: 4px 10px; + font-size: 0.68rem; + color: var(--ad-muted); + border-top: 1px solid var(--ad-border); +} +.drawer-upload-progress { + height: 3px; + background: var(--ad-accent); + border-radius: 2px; + width: 0%; + transition: width 0.2s; + margin-bottom: 3px; +} + +#image_list { padding: 4px 2px; } +#image_list a.annotate_image_link { + display: block; padding: 5px 12px; border-radius: 5px; + font-size: .8rem; color: var(--ad-muted); text-decoration: none; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background .1s, color .1s; +} +#image_list a.annotate_image_link:hover { background: var(--ad-surf2); color: var(--ad-text); } +#image_list a.annotate_image_link.active { + background: rgba(92,106,246,.15); color: var(--ad-acc2); font-weight: 500; +} + +.drawer-nav-btns { display: flex; gap: 6px; } +.drawer-nav-btns .btn { flex: 1; padding: 4px 0; font-size: .75rem; } +.drawer-nav-btns .btn-primary { background: var(--ad-accent); border-color: var(--ad-accent); } +.drawer-nav-btns .btn-success { background: #16a34a; border-color: #16a34a; } +.drawer-nav-btns .btn-danger { background: #dc2626; border-color: #dc2626; } + +/* Generic dark forms/tables inside drawers */ +.anno-drawer .table { color: var(--ad-text); font-size: .79rem; margin: 0; } +.anno-drawer .table thead th { + background: var(--ad-surf2); color: var(--ad-muted); + border-color: var(--ad-border); font-size: .7rem; +} +.anno-drawer .table-striped tbody tr:nth-of-type(odd) { background: rgba(255,255,255,.03); } +.anno-drawer .table-bordered td, +.anno-drawer .table-bordered th { border-color: var(--ad-border); } +.anno-drawer tbody tr { border-bottom: 1px solid var(--ad-border); } + +.anno-drawer .form-control, +.anno-drawer select { + background: var(--ad-surf2) !important; border-color: var(--ad-border) !important; + color: var(--ad-text) !important; font-size: .79rem !important; +} +.anno-drawer .form-control:focus { + background: var(--ad-surf2) !important; border-color: var(--ad-accent) !important; + color: var(--ad-text) !important; box-shadow: 0 0 0 2px rgba(92,106,246,.2) !important; +} +.anno-drawer textarea { color: var(--ad-text) !important; background: var(--ad-surf2) !important; } +.anno-drawer label { color: var(--ad-muted); font-size: .78rem; } +.anno-drawer .input-group-btn .btn-primary { background: var(--ad-accent); border-color: var(--ad-accent); } +.anno-drawer .card { background: var(--ad-surf2); border-color: var(--ad-border); } +.anno-drawer .card-header { background: var(--ad-surf2); border-bottom-color: var(--ad-border); color: var(--ad-text); } +.anno-drawer .progress { background: var(--ad-surf2); } +.anno-drawer a[data-toggle="collapse"] { color: var(--ad-acc2); } +.anno-drawer .btn-primary { background: var(--ad-accent); border-color: var(--ad-accent); } +.anno-drawer .btn-success { background: #16a34a; border-color: #16a34a; } +.anno-drawer .btn-outline-secondary { border-color: var(--ad-border); color: var(--ad-muted); } + +/* Filter rows */ +.filter-row { + display: flex; align-items: center; gap: 6px; + padding: 7px 10px; border-bottom: 1px solid var(--ad-border); +} +.filter-row label { min-width: 72px; margin: 0; color: var(--ad-text); font-size: .78rem; } + +/* ─────────────────────── THUMBNAIL PANEL ──────────────────────── */ +.anno-thumbs { + position: absolute; + right: 0; top: 0; bottom: 0; + width: 190px; + background: var(--ad-surf); + border-left: 1px solid var(--ad-border); + overflow-y: auto; + transform: translateX(100%); + transition: transform .22s cubic-bezier(.25,.8,.25,1); + z-index: 50; + padding: 8px; + box-shadow: -4px 0 16px rgba(0,0,0,.4); +} +.anno-thumbs.thumbs-open { transform: translateX(0); } +.anno-thumbs::-webkit-scrollbar { width: 4px; } +.anno-thumbs::-webkit-scrollbar-thumb { background: var(--ad-border); } +.anno-thumbs .card { background: var(--ad-surf2); border-color: var(--ad-border); margin-bottom: 8px; } diff --git a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js index 7b9d258b..8d390ff7 100644 --- a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js +++ b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js @@ -476,11 +476,14 @@ class EXACTViewer { this.overlaySlider = new Slider("#overlaySlider", { ticks: [0, 25, 50, 75, 100], - ticks_labels: ['0', '25%', '50% Opacity', '75%', '100%'], - //tooltip: 'always', ticks_snap_bounds: 1, + tooltip: 'hide', value: 100 }); + var overlayValLabel = document.getElementById('overlayValLabel'); + this.overlaySlider.on('change', function (e) { + if (overlayValLabel) overlayValLabel.textContent = e.newValue + '%'; + }); this.overlaySlider.on('change', this.updateOverlayRegImageSlider.bind(this)); viewer.addHandler("updateOverlayImageSlider", function (event) { @@ -488,6 +491,8 @@ class EXACTViewer { var opacity = event.opacity; this.userData.overlaySlider.setValue(opacity); this.userData.updateOverlayRegImageSlider(opacity); + var lbl = document.getElementById('overlayValLabel'); + if (lbl) lbl.textContent = opacity + '%'; }, this); @@ -598,10 +603,17 @@ class EXACTViewer { ticks: ticks_to_use, scale: 'logarithmic', ticks_labels: labels_to_use, - tooltip: 'always', + tooltip: 'hide', ticks_snap_bounds: 1, value: 0 }); + var zoomValLabel = document.getElementById('zoomValLabel'); + this.gZoomSlider.on('change', function (e) { + if (zoomValLabel) { + var idx = ticks_to_use.indexOf(e.newValue); + zoomValLabel.textContent = idx >= 0 ? labels_to_use[idx] : ''; + } + }); this.gZoomSlider.on('change', this.onSliderChanged.bind(this)); } diff --git a/exact/exact/annotations/static/annotations/js/segmentationTool.js b/exact/exact/annotations/static/annotations/js/segmentationTool.js index 61905605..2326e4a9 100644 --- a/exact/exact/annotations/static/annotations/js/segmentationTool.js +++ b/exact/exact/annotations/static/annotations/js/segmentationTool.js @@ -61,13 +61,19 @@ this._preStrokeSnap = null; // Tile state - this.tileCache = new Map(); // `${tx}_${ty}` → Uint8Array | null(loading) + this.tileCache = new Map(); // `${tx}_${ty}` → Uint8Array | null(queued/loading) this.renderCache = new Map(); // `${tx}_${ty}` → ImageBitmap (colored, ready to blit) this.dirtyTiles = new Set(); this.imgTileCache = new Map(); // `${tx}_${ty}` → ImageBitmap (raw image pixels) this._dziMaxLevel = null; this._dziTileSize = 254; + // Fetch queue — limits concurrent segmentation tile requests so the + // server is not flooded when a large WSI is viewed at low zoom. + this._fetchQueue = []; // {tx, ty, k} not yet started + this._activeFetches = 0; + this._maxConcurrentFetches = 4; + this._setupCanvases(); this._bindViewerEvents(); this._bindDrawEvents(); @@ -293,10 +299,28 @@ return this.tileCache.get(k); } - async _ensureTile(tx, ty) { + // Queue a tile fetch (called from _redraw). Marks the tile as pending + // immediately so subsequent redraws won't re-enqueue it. + _ensureTile(tx, ty) { const k = this._tileKey(tx, ty); if (this.tileCache.has(k)) return; - this.tileCache.set(k, null); + this.tileCache.set(k, null); // mark queued + this._fetchQueue.push({ tx, ty, k }); + this._drainFetchQueue(); + } + + _drainFetchQueue() { + while (this._activeFetches < this._maxConcurrentFetches && this._fetchQueue.length > 0) { + const job = this._fetchQueue.shift(); + this._activeFetches++; + this._doFetchTile(job.tx, job.ty, job.k).finally(() => { + this._activeFetches--; + this._drainFetchQueue(); + }); + } + } + + async _doFetchTile(tx, ty, k) { try { const r = await fetch( `/annotations/api/segmentation/${this.annotationId}/tiles/${this.plane}/${tx}/${ty}/?frame=${this.frame}&ph=${this.imageHeight}&nf=${this.nFrames}`, @@ -317,7 +341,6 @@ this._prerenderTile(tx, ty); // draw into displayCanvas immediately } } catch (_) { this.tileCache.delete(k); } - // No full _redraw() needed — tile was painted directly into displayCanvas. } // ── Display ────────────────────────────────────────────────────── @@ -391,6 +414,11 @@ this._drawCursorShape(dCtx, this._cursorVpX, this._cursorVpY); } + // Drop queued-but-not-started fetches from the previous viewport so + // they don't block tiles needed for the current view. + this._fetchQueue.forEach(({ k }) => this.tileCache.delete(k)); + this._fetchQueue = []; + // Trigger tile fetches for newly visible tiles (display canvas will // update incrementally as each tile arrives). const vp = this.viewer.viewport; @@ -399,6 +427,17 @@ new OpenSeadragon.Point(bounds.x, bounds.y)); const brImg = item.viewportToImageCoordinates( new OpenSeadragon.Point(bounds.x + bounds.width, bounds.y + bounds.height)); + + // Hide overlay and skip fetching when zoomed out so far that individual + // pixels are invisible — avoids flooding the server with hundreds of + // tile requests and prevents unbounded tileCache growth on large WSIs. + const MAX_FETCH_PIXELS = 4096; + if ((brImg.x - tlImg.x) > MAX_FETCH_PIXELS || (brImg.y - tlImg.y) > MAX_FETCH_PIXELS) { + this.displayCanvas.style.display = 'none'; + return; + } + this.displayCanvas.style.display = ''; + const tx0 = Math.max(0, Math.floor(tlImg.x / TILE_SIZE)); const ty0 = Math.max(0, Math.floor(tlImg.y / TILE_SIZE)); const tx1 = Math.min(Math.ceil(this.imageWidth / TILE_SIZE) - 1, diff --git a/exact/exact/annotations/templates/annotations/annotate_v3.html b/exact/exact/annotations/templates/annotations/annotate_v3.html new file mode 100644 index 00000000..aadaee38 --- /dev/null +++ b/exact/exact/annotations/templates/annotations/annotate_v3.html @@ -0,0 +1,1105 @@ +{% extends 'annotations/base.html' %} +{% load i18n %} +{% load static %} + +{% block additional_annotation_js %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock additional_annotation_js %} + +{% block bodyblock %} + +
+ + + + + + + + + + + + + + + + + + + {% if show_processing %} + + {% endif %} + + {% if annotation_types %} + + {% endif %} + + {% if HasMediaFiles %} + + {% endif %} + + + + {% if asthma %} + + {% endif %} + + + + +
+ + +
+ + +
+
+
+ Zoom +
+ +
+
+ Opacity +
+ 100% +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
{# /anno-osd-area #} + + +
+
+ +
+ +
{# /anno-viewer #} + + +
+
+ {% for set_image in set_images %} +
+ + Loading thumbnail + +
+ {% endfor %} +
+
+ + + + + + +
+ {% if show_advanced_options %} + + + {% else %} + + + {% endif %} +
+ + + + + + + + +
+
+ {{ selected_image.name }} + +
+
+ {% if annotation_types %} + + + + + + + + + + + + {% for annotation_type in annotation_types %} + + + + + + + + {% endfor %} + +
LabelKey#Vis
+ {{ annotation_type.name }} + {% if annotation_type.vector_type == 8 %} + seg + {% endif %} + {{ forloop.counter }} + +
+ {% endif %} + {% if global_annotation_types %} +
+
Image labels
+ {% for annotation_type in global_annotation_types %} +
+ + ⇧{{ forloop.counter }} + +
+ {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} + +
{# /anno-float-panel #} + +
{# /annotator-shell #} + + + +{% if 'edit_set' in imageset_perms and not imageset_lock %} + +{% endif %} + +{% endblock %} diff --git a/exact/exact/annotations/views.py b/exact/exact/annotations/views.py index 9e4bab47..f27f8953 100644 --- a/exact/exact/annotations/views.py +++ b/exact/exact/annotations/views.py @@ -84,7 +84,13 @@ def annotate(request, image_id): if hasattr(cache, "delete_pattern"): cache.set(f"{selected_image.image_set.id}_contains_asthma", asthma, 5*60) - template = 'annotations/annotate_v2.html' if hasattr(request.user,'prefs') and hasattr(request.user.prefs,'frontend') and request.user.prefs.frontend==2 else 'annotations/annotate.html' + _fe = request.user.prefs.frontend if hasattr(request.user, 'prefs') and hasattr(request.user.prefs, 'frontend') else 1 + if _fe == 3: + template = 'annotations/annotate_v3.html' + elif _fe == 2: + template = 'annotations/annotate_v2.html' + else: + template = 'annotations/annotate.html' response = render(request, template, { 'team': selected_image.image_set.team, diff --git a/exact/exact/base/templates/base/base.html b/exact/exact/base/templates/base/base.html index 94496d65..02e13f20 100644 --- a/exact/exact/base/templates/base/base.html +++ b/exact/exact/base/templates/base/base.html @@ -7,7 +7,7 @@ - {% if frontend == 2 %} + {% if frontend >= 2 %} {% else %} @@ -29,7 +29,7 @@ {% block selectorblock %}{% endblock %} - {% if frontend == 2 %} + {% if frontend >= 2 %}