diff --git a/app/grandchallenge/algorithms/admin.py b/app/grandchallenge/algorithms/admin.py index abb22e0753..c4c19d5a4e 100644 --- a/app/grandchallenge/algorithms/admin.py +++ b/app/grandchallenge/algorithms/admin.py @@ -10,6 +10,9 @@ AlgorithmImage, AlgorithmImageGroupObjectPermission, AlgorithmImageUserObjectPermission, + AlgorithmModel, + AlgorithmModelGroupObjectPermission, + AlgorithmModelUserObjectPermission, AlgorithmPermissionRequest, AlgorithmUserObjectPermission, Job, @@ -110,6 +113,15 @@ class AlgorithmPermissionRequestAdmin(GuardedModelAdmin): readonly_fields = ("user", "algorithm") +@admin.register(AlgorithmModel) +class AlgorithmModelAdmin(GuardedModelAdmin): + exclude = ("model",) + list_display = ("algorithm", "created", "is_desired_version", "comment") + list_filter = ("is_desired_version",) + search_fields = ("algorithm__title", "comment") + readonly_fields = ("creator", "algorithm", "sha256", "size_in_storage") + + admin.site.register(AlgorithmUserObjectPermission, UserObjectPermissionAdmin) admin.site.register(AlgorithmGroupObjectPermission, GroupObjectPermissionAdmin) admin.site.register(AlgorithmImage, ComponentImageAdmin) @@ -121,3 +133,9 @@ class AlgorithmPermissionRequestAdmin(GuardedModelAdmin): ) admin.site.register(JobUserObjectPermission, UserObjectPermissionAdmin) admin.site.register(JobGroupObjectPermission, GroupObjectPermissionAdmin) +admin.site.register( + AlgorithmModelUserObjectPermission, UserObjectPermissionAdmin +) +admin.site.register( + AlgorithmModelGroupObjectPermission, GroupObjectPermissionAdmin +) diff --git a/app/grandchallenge/algorithms/forms.py b/app/grandchallenge/algorithms/forms.py index f8c4cb5f8c..2949f5fd4f 100644 --- a/app/grandchallenge/algorithms/forms.py +++ b/app/grandchallenge/algorithms/forms.py @@ -14,10 +14,11 @@ ) from dal import autocomplete from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.files.base import ContentFile from django.core.validators import RegexValidator -from django.db.models import Count, Q +from django.db.models import Count, Exists, OuterRef, Q from django.db.transaction import on_commit from django.forms import ( CharField, @@ -41,6 +42,7 @@ from grandchallenge.algorithms.models import ( Algorithm, AlgorithmImage, + AlgorithmModel, AlgorithmPermissionRequest, Job, ) @@ -48,7 +50,10 @@ AlgorithmImageSerializer, AlgorithmSerializer, ) -from grandchallenge.algorithms.tasks import import_remote_algorithm_image +from grandchallenge.algorithms.tasks import ( + assign_algorithm_model_from_upload, + import_remote_algorithm_image, +) from grandchallenge.components.form_fields import InterfaceFormField from grandchallenge.components.forms import ContainerImageForm from grandchallenge.components.models import ( @@ -72,6 +77,8 @@ from grandchallenge.hanging_protocols.models import VIEW_CONTENT_SCHEMA from grandchallenge.reader_studies.models import ReaderStudy from grandchallenge.subdomains.utils import reverse, reverse_lazy +from grandchallenge.uploads.models import UserUpload +from grandchallenge.uploads.widgets import UserUploadSingleWidget from grandchallenge.workstations.models import Workstation @@ -85,6 +92,9 @@ class JobCreateForm(SaveFormInitMixin, Form): algorithm_image = ModelChoiceField( queryset=None, disabled=True, required=True, widget=HiddenInput ) + algorithm_model = ModelChoiceField( + queryset=None, disabled=True, required=False, widget=HiddenInput + ) def __init__(self, *args, algorithm, user, **kwargs): super().__init__(*args, **kwargs) @@ -95,6 +105,7 @@ def __init__(self, *args, algorithm, user, **kwargs): self.helper = FormHelper() active_image = self._algorithm.active_image + active_model = self._algorithm.active_model if active_image: self.fields["algorithm_image"].queryset = ( @@ -102,6 +113,12 @@ def __init__(self, *args, algorithm, user, **kwargs): ) self.fields["algorithm_image"].initial = active_image + if active_model: + self.fields["algorithm_model"].queryset = ( + AlgorithmModel.objects.filter(pk=active_model.pk) + ) + self.fields["algorithm_model"].initial = active_model + for inp in self._algorithm.inputs.all(): self.fields[inp.slug] = InterfaceFormField( instance=inp, @@ -335,6 +352,12 @@ def get_phase_algorithm_inputs_outputs(self): @cached_property def user_algorithms_for_phase(self): inputs, outputs = self.get_phase_algorithm_inputs_outputs() + desired_image_subquery = AlgorithmImage.objects.filter( + algorithm=OuterRef("pk"), is_desired_version=True + ) + desired_model_subquery = AlgorithmModel.objects.filter( + algorithm=OuterRef("pk"), is_desired_version=True + ) return ( get_objects_for_user(self._user, "algorithms.change_algorithm") .annotate( @@ -346,6 +369,19 @@ def user_algorithms_for_phase(self): relevant_output_count=Count( "outputs", filter=Q(outputs__in=outputs), distinct=True ), + has_active_image=Exists(desired_image_subquery), + active_image_pk=desired_image_subquery.values_list( + "pk", flat=True + ), + active_model_pk=desired_model_subquery.values_list( + "pk", flat=True + ), + active_image_comment=desired_image_subquery.values_list( + "comment", flat=True + ), + active_model_comment=desired_model_subquery.values_list( + "comment", flat=True + ), ) .filter( total_input_count=len(inputs), @@ -355,16 +391,6 @@ def user_algorithms_for_phase(self): ) ) - @cached_property - def user_active_images_for_phase(self): - return get_objects_for_user( - user=self._user, - perms="algorithms.change_algorithmimage", - klass=AlgorithmImage.objects.active_images() - .select_related("algorithm") - .filter(algorithm__in=self.user_algorithms_for_phase), - ) - @cached_property def user_algorithm_count(self): return self.user_algorithms_for_phase.count() @@ -1161,3 +1187,71 @@ def _save_new_algorithm_image(self): } ).apply_async ) + + +class AlgorithmModelForm(SaveFormInitMixin, ModelForm): + algorithm = ModelChoiceField(widget=HiddenInput(), queryset=None) + user_upload = ModelChoiceField( + widget=UserUploadSingleWidget( + allowed_file_types=[ + "application/x-gzip", + "application/gzip", + ] + ), + label="Algorithm Model", + queryset=None, + help_text=( + ".tar.gz file of the algorithm model that will be extracted" + " to /opt/ml/model/ during inference" + ), + ) + creator = ModelChoiceField( + widget=HiddenInput(), + queryset=( + get_user_model() + .objects.exclude(username=settings.ANONYMOUS_USER_NAME) + .filter(verification__is_verified=True) + ), + ) + + def __init__(self, *args, user, algorithm, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["user_upload"].queryset = get_objects_for_user( + user, + "uploads.change_userupload", + ).filter(status=UserUpload.StatusChoices.COMPLETED) + + self.fields["creator"].initial = user + self.fields["algorithm"].queryset = Algorithm.objects.filter( + pk=algorithm.pk + ) + self.fields["algorithm"].initial = algorithm + + def clean_creator(self): + creator = self.cleaned_data["creator"] + + if AlgorithmModel.objects.filter( + import_status=ImportStatusChoices.INITIALIZED, + creator=creator, + ).exists(): + self.add_error( + None, + "You have an existing model importing, please wait for it to complete", + ) + + return creator + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + on_commit( + assign_algorithm_model_from_upload.signature( + kwargs={"algorithm_model_pk": instance.pk}, + immutable=True, + ).apply_async + ) + return instance + + class Meta: + model = AlgorithmModel + fields = ("algorithm", "user_upload", "creator", "comment") diff --git a/app/grandchallenge/algorithms/migrations/0049_algorithmmodel_job_algorithm_model_and_more.py b/app/grandchallenge/algorithms/migrations/0049_algorithmmodel_job_algorithm_model_and_more.py new file mode 100644 index 0000000000..96f55a7a25 --- /dev/null +++ b/app/grandchallenge/algorithms/migrations/0049_algorithmmodel_job_algorithm_model_and_more.py @@ -0,0 +1,212 @@ +# Generated by Django 4.2.13 on 2024-05-31 14:20 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import grandchallenge.algorithms.models +import grandchallenge.core.storage +import grandchallenge.core.validators +import grandchallenge.uploads.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("uploads", "0006_userupload_mimetype"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("algorithms", "0048_job_detailed_error_message"), + ] + + operations = [ + migrations.CreateModel( + name="AlgorithmModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "import_status", + models.PositiveSmallIntegerField( + choices=[ + (0, "Initialized"), + (1, "Queued"), + (2, "Re-Queued"), + (3, "Started"), + (4, "Cancelled"), + (5, "Failed"), + (6, "Completed"), + ], + db_index=True, + default=0, + ), + ), + ("status", models.TextField(editable=False)), + ( + "model", + models.FileField( + blank=True, + help_text=".tar.gz file of the algorithm model that will be extracted to /opt/ml/model/ during inference", + storage=grandchallenge.core.storage.ProtectedS3Storage(), + upload_to=grandchallenge.algorithms.models.algorithm_models_path, + validators=[ + grandchallenge.core.validators.ExtensionValidator( + allowed_extensions=(".tar.gz",) + ) + ], + ), + ), + ("sha256", models.CharField(editable=False, max_length=71)), + ( + "size_in_storage", + models.PositiveBigIntegerField( + default=0, + editable=False, + help_text="The number of bytes stored in the storage backend", + ), + ), + ( + "comment", + models.TextField( + blank=True, + default="", + help_text="Add any information (e.g. version ID) about this image here.", + ), + ), + ( + "is_desired_version", + models.BooleanField(default=False, editable=False), + ), + ( + "algorithm", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="algorithm_models", + to="algorithms.algorithm", + ), + ), + ( + "creator", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user_upload", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="uploads.userupload", + validators=[ + grandchallenge.uploads.validators.validate_gzip_mimetype + ], + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="job", + name="algorithm_model", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="algorithms.algorithmmodel", + ), + ), + migrations.CreateModel( + name="AlgorithmModelUserObjectPermission", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="algorithms.algorithmmodel", + ), + ), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="auth.permission", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + "unique_together": {("user", "permission", "content_object")}, + }, + ), + migrations.CreateModel( + name="AlgorithmModelGroupObjectPermission", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="algorithms.algorithmmodel", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="auth.group", + ), + ), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="auth.permission", + ), + ), + ], + options={ + "abstract": False, + "unique_together": {("group", "permission", "content_object")}, + }, + ), + ] diff --git a/app/grandchallenge/algorithms/models.py b/app/grandchallenge/algorithms/models.py index dafba01804..8bcc9d227c 100644 --- a/app/grandchallenge/algorithms/models.py +++ b/app/grandchallenge/algorithms/models.py @@ -9,14 +9,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models +from django.db import models, transaction from django.db.models import Count, Min, Q, Sum from django.db.models.signals import post_delete from django.db.transaction import on_commit from django.dispatch import receiver -from django.template.defaultfilters import truncatewords +from django.template.defaultfilters import truncatechars, truncatewords from django.utils import timezone from django.utils.functional import cached_property +from django.utils.text import get_valid_filename from django_extensions.db.models import TitleSlugDescriptionModel from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from guardian.shortcuts import assign_perm, remove_perm @@ -38,6 +39,7 @@ from grandchallenge.core.storage import ( get_logo_path, get_social_image_path, + protected_s3_storage, public_s3_storage, ) from grandchallenge.core.templatetags.bleach import md2html @@ -45,6 +47,7 @@ AccessRequestHandlingOptions, process_access_request, ) +from grandchallenge.core.validators import ExtensionValidator from grandchallenge.credits.models import Credit from grandchallenge.evaluation.utils import get from grandchallenge.hanging_protocols.models import HangingProtocolMixin @@ -53,6 +56,8 @@ from grandchallenge.publications.models import Publication from grandchallenge.reader_studies.models import DisplaySet from grandchallenge.subdomains.utils import reverse +from grandchallenge.uploads.models import UserUpload +from grandchallenge.uploads.validators import validate_gzip_mimetype from grandchallenge.workstations.models import Workstation logger = logging.getLogger(__name__) @@ -344,6 +349,18 @@ def active_image(self): except ObjectDoesNotExist: return None + @cached_property + def active_model(self): + """ + Returns + ------- + The desired model version for this algorithm or None + """ + try: + return self.algorithm_models.filter(is_desired_version=True).get() + except ObjectDoesNotExist: + return None + @property def image_upload_in_progress(self): return self.algorithm_container_images.filter( @@ -484,6 +501,15 @@ def public_test_case(self): except AttributeError: return False + def form_field_label(self): + title = f"{self.title}" + title += f" (Active image: {' - '.join(filter(None, [truncatechars(self.active_image_comment, 25), str(self.active_image_pk)]))})" + if self.active_model_pk: + title += f" (Active model: {' - '.join(filter(None, [truncatechars(self.active_model_comment, 25), str(self.active_model_pk)]))})" + else: + title += " (Active model: None)" + return title + class AlgorithmUserObjectPermission(UserObjectPermissionBase): content_object = models.ForeignKey(Algorithm, on_delete=models.CASCADE) @@ -619,12 +645,136 @@ def spent_credits(self, user): ) +def algorithm_models_path(instance, filename): + return ( + f"models/" + f"{instance._meta.app_label.lower()}/" + f"{instance._meta.model_name.lower()}/" + f"{instance.pk}/" + f"{get_valid_filename(filename)}" + ) + + +class AlgorithmModel(UUIDModel): + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + algorithm = models.ForeignKey( + Algorithm, on_delete=models.PROTECT, related_name="algorithm_models" + ) + import_status = models.PositiveSmallIntegerField( + choices=ImportStatusChoices.choices, + default=ImportStatusChoices.INITIALIZED, + db_index=True, + ) + status = models.TextField(editable=False) + user_upload = models.ForeignKey( + UserUpload, + blank=True, + null=True, + on_delete=models.SET_NULL, + validators=[validate_gzip_mimetype], + ) + model = models.FileField( + blank=True, + upload_to=algorithm_models_path, + validators=[ExtensionValidator(allowed_extensions=(".tar.gz",))], + help_text=( + ".tar.gz file of the algorithm model that will be extracted to /opt/ml/model/ during inference" + ), + storage=protected_s3_storage, + ) + sha256 = models.CharField(editable=False, max_length=71) + size_in_storage = models.PositiveBigIntegerField( + editable=False, + default=0, + help_text="The number of bytes stored in the storage backend", + ) + comment = models.TextField( + blank=True, + default="", + help_text="Add any information (e.g. version ID) about this image here.", + ) + is_desired_version = models.BooleanField(default=False, editable=False) + + def save(self, *args, **kwargs): + adding = self._state.adding + + super().save(*args, **kwargs) + + if adding: + self.assign_permissions() + + def assign_permissions(self): + # Editors can view this algorithm model + assign_perm( + f"view_{self._meta.model_name}", self.algorithm.editors_group, self + ) + # Editors can change this algorithm model + assign_perm( + f"change_{self._meta.model_name}", + self.algorithm.editors_group, + self, + ) + + def get_peer_models(self): + return AlgorithmModel.objects.filter(algorithm=self.algorithm).exclude( + pk=self.pk + ) + + @transaction.atomic + def mark_desired_version(self, peer_models=None): + models = list(peer_models or self.get_peer_models()) + for model in models: + model.is_desired_version = False + self.is_desired_version = True + models.append(self) + self.__class__.objects.bulk_update(models, ["is_desired_version"]) + + def get_absolute_url(self): + return reverse( + "algorithms:model-detail", + kwargs={"slug": self.algorithm.slug, "pk": self.pk}, + ) + + @property + def import_status_context(self): + if self.import_status == ImportStatusChoices.COMPLETED: + return "success" + elif self.import_status in { + ImportStatusChoices.FAILED, + ImportStatusChoices.CANCELLED, + }: + return "danger" + else: + return "info" + + @property + def import_in_progress(self): + return self.import_status == ImportStatusChoices.INITIALIZED + + +class AlgorithmModelUserObjectPermission(UserObjectPermissionBase): + content_object = models.ForeignKey( + AlgorithmModel, on_delete=models.CASCADE + ) + + +class AlgorithmModelGroupObjectPermission(GroupObjectPermissionBase): + content_object = models.ForeignKey( + AlgorithmModel, on_delete=models.CASCADE + ) + + class Job(UUIDModel, ComponentJob): objects = JobManager.as_manager() algorithm_image = models.ForeignKey( AlgorithmImage, on_delete=models.PROTECT ) + algorithm_model = models.ForeignKey( + AlgorithmModel, on_delete=models.PROTECT, null=True, blank=True + ) creator = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL ) @@ -791,6 +941,13 @@ def sort_inputs_and_execute( ).apply_async ) + @property + def executor_kwargs(self): + executor_kwargs = super().executor_kwargs + if self.algorithm_model: + executor_kwargs["algorithm_model"] = self.algorithm_model.model + return executor_kwargs + @cached_property def slug_to_output(self): outputs = {} diff --git a/app/grandchallenge/algorithms/serializers.py b/app/grandchallenge/algorithms/serializers.py index 57569538ec..33d0760c02 100644 --- a/app/grandchallenge/algorithms/serializers.py +++ b/app/grandchallenge/algorithms/serializers.py @@ -183,6 +183,7 @@ def validate(self, data): ) data["creator"] = user data["algorithm_image"] = alg.active_image + data["algorithm_model"] = alg.active_model jobs_limit = alg.active_image.algorithm.get_jobs_limit( user=data["creator"] diff --git a/app/grandchallenge/algorithms/tasks.py b/app/grandchallenge/algorithms/tasks.py index e3b98744de..eb9ab6312a 100644 --- a/app/grandchallenge/algorithms/tasks.py +++ b/app/grandchallenge/algorithms/tasks.py @@ -1,4 +1,6 @@ import logging +from base64 import b64decode +from binascii import hexlify from tempfile import TemporaryDirectory from typing import NamedTuple @@ -8,7 +10,7 @@ from django.conf import settings from django.core.cache import cache from django.core.files.base import File -from django.db import transaction +from django.db import OperationalError, transaction from django.db.models import Count, Q from django.db.transaction import on_commit from django.utils._os import safe_join @@ -18,6 +20,7 @@ from grandchallenge.algorithms.models import Algorithm, AlgorithmImage, Job from grandchallenge.archives.models import Archive from grandchallenge.cases.tasks import build_images +from grandchallenge.components.models import ImportStatusChoices from grandchallenge.components.tasks import ( _retry, add_file_to_component_interface_value, @@ -202,6 +205,7 @@ def retry_with_delay(): ): create_algorithm_jobs( algorithm_image=algorithm.active_image, + algorithm_model=algorithm.active_model, civ_sets=[ {*ai.values.all()} for ai in archive_items.prefetch_related( @@ -223,6 +227,7 @@ def create_algorithm_jobs( *, algorithm_image, civ_sets, + algorithm_model=None, extra_viewer_groups=None, extra_logs_viewer_groups=None, max_jobs=None, @@ -276,6 +281,7 @@ def create_algorithm_jobs( job = Job.objects.create( creator=None, # System jobs, so no creator algorithm_image=algorithm_image, + algorithm_model=algorithm_model, task_on_success=task_on_success, task_on_failure=task_on_failure, time_limit=time_limit, @@ -478,3 +484,77 @@ def set_credits_per_job(): algorithm.credits_per_job = default_credits_per_job algorithm.save(update_fields=("credits_per_job",)) + + +@shared_task(**settings.CELERY_TASK_DECORATOR_KWARGS["acks-late-2xlarge"]) +@transaction.atomic +def assign_algorithm_model_from_upload(*, algorithm_model_pk, retries=0): + from grandchallenge.algorithms.models import AlgorithmModel + + try: + # try to acquire lock + current_model = ( + AlgorithmModel.objects.filter(pk=algorithm_model_pk) + .select_for_update(nowait=True) + .get() + ) + peer_models = current_model.get_peer_models().select_for_update( + nowait=True + ) + except OperationalError: + # failed to acquire lock + _retry( + task=assign_algorithm_model_from_upload, + signature_kwargs={ + "kwargs": { + "algorithm_model_pk": algorithm_model_pk, + }, + "immutable": True, + }, + retries=retries, + ) + return + + current_model.user_upload.copy_object(to_field=current_model.model) + + sha256 = get_object_sha256(current_model.model) + if ( + AlgorithmModel.objects.filter(sha256=sha256) + .exclude(pk=current_model.pk) + .exists() + ): + current_model.import_status = ImportStatusChoices.FAILED + current_model.status = ( + "Algorithm model with this sha256 already exists." + ) + current_model.save() + + current_model.model.delete() + current_model.user_upload.delete() + + return + + current_model.sha256 = sha256 + current_model.size_in_storage = current_model.model.size + current_model.import_status = ImportStatusChoices.COMPLETED + current_model.save() + + current_model.user_upload.delete() + + # mark as desired version and pass locked peer models directly since else + # mark_desired_version will fail trying to access the locked models + current_model.mark_desired_version(peer_models=peer_models) + + +def get_object_sha256(file_field): + response = file_field.storage.connection.meta.client.head_object( + Bucket=file_field.storage.bucket.name, + Key=file_field.name, + ChecksumMode="ENABLED", + ) + + # The checksums are not calculated on minio + if sha256 := response.get("ChecksumSHA256"): + return f"sha256:{hexlify(b64decode(sha256)).decode('utf-8')}" + else: + return "" diff --git a/app/grandchallenge/algorithms/templates/algorithms/algorithm_detail.html b/app/grandchallenge/algorithms/templates/algorithms/algorithm_detail.html index aedf66adf0..4529153ae0 100644 --- a/app/grandchallenge/algorithms/templates/algorithms/algorithm_detail.html +++ b/app/grandchallenge/algorithms/templates/algorithms/algorithm_detail.html @@ -44,6 +44,12 @@ {% endif %} {# @formatter:on #} + +  Models +  Editors @@ -161,7 +167,7 @@

About

{% if object.active_image %}
-
Version:
+
Image Version:
{{ object.active_image.pk }}
@@ -170,6 +176,17 @@

About

{% endif %} + {% if object.active_model %} +
+
Model Version:
+
{{ object.active_model.pk }}
+
+
+
Last updated:
+
{{ object.active_model.created }}
+
+ {% endif %} + {% if object.publications.all %}
Associated publication{{ object.publications.all|pluralize }}: @@ -505,9 +522,61 @@

Container Images

{% endfor %}
+ +
+ +

Algorithm Models

+

You can upload your algorithm's model separately from the container. If provided, the model will be extracted to /opt/ml/model/ during inference.

+ + {% if not object.active_model %} +

+ Currently, this algorithm does not have a model associated with it. +

+ {% endif %} + +

+ + Upload a Model + +

+ + +
{% endif %}
+ {# modal #}