From 746cffb47659a6a034fcc706d258cdc63be812f8 Mon Sep 17 00:00:00 2001 From: Andrey Zhavoronkov <41117609+azhavoro@users.noreply.github.com> Date: Wed, 30 Jan 2019 16:01:15 +0300 Subject: [PATCH] Model manager for DL models to preannotate images (#289) --- cvat/apps/auto_annotation/README.md | 9 +- cvat/apps/auto_annotation/__init__.py | 8 +- cvat/apps/auto_annotation/apps.py | 6 +- .../migrations/0001_initial.py | 39 + cvat/apps/auto_annotation/model_manager.py | 143 ++++ cvat/apps/auto_annotation/models.py | 51 ++ cvat/apps/auto_annotation/permissions.py | 29 + .../auto_annotation/js/auto_annotation.js | 188 ----- .../auto_annotation/js/dashboardPlugin.js | 792 ++++++++++++++++++ .../static/auto_annotation/stylesheet.css | 83 ++ cvat/apps/auto_annotation/urls.py | 11 +- cvat/apps/auto_annotation/views.py | 268 ++++-- .../static/dashboard/js/dashboard.js | 14 +- .../dashboard/static/dashboard/stylesheet.css | 7 +- .../templates/dashboard/dashboard.html | 29 +- cvat/apps/dashboard/views.py | 3 +- cvat/apps/engine/static/engine/base.css | 30 + .../engine/static/engine/js/annotationUI.js | 6 +- cvat/apps/engine/static/engine/js/base.js | 4 +- .../engine/static/engine/js/shapeBuffer.js | 4 +- cvat/apps/engine/static/engine/stylesheet.css | 34 - .../engine/templates/engine/annotation.html | 3 + cvat/apps/engine/views.py | 3 +- .../static/tf_annotation/js/tf_annotation.js | 6 +- cvat/settings/base.py | 3 + docker-compose.yml | 2 + 26 files changed, 1421 insertions(+), 354 deletions(-) create mode 100644 cvat/apps/auto_annotation/migrations/0001_initial.py create mode 100644 cvat/apps/auto_annotation/model_manager.py create mode 100644 cvat/apps/auto_annotation/permissions.py delete mode 100644 cvat/apps/auto_annotation/static/auto_annotation/js/auto_annotation.js create mode 100644 cvat/apps/auto_annotation/static/auto_annotation/js/dashboardPlugin.js create mode 100644 cvat/apps/auto_annotation/static/auto_annotation/stylesheet.css diff --git a/cvat/apps/auto_annotation/README.md b/cvat/apps/auto_annotation/README.md index 3b618995..23e4aaa5 100644 --- a/cvat/apps/auto_annotation/README.md +++ b/cvat/apps/auto_annotation/README.md @@ -2,8 +2,9 @@ ### Description -The application will be enabled automatically if OpenVINO™ component is -installed. It allows to use custom models for auto annotation. Only models in +The application will be enabled automatically if +[OpenVINO™ component](../../../components/openvino) +is installed. It allows to use custom models for auto annotation. Only models in OpenVINO™ toolkit format are supported. If you would like to annotate a task with a custom model please convert it to the intermediate representation (IR) format via the model optimizer tool. See [OpenVINO documentation](https://software.intel.com/en-us/articles/OpenVINO-InferEngine) for details. @@ -14,9 +15,7 @@ To annotate a task with a custom model you need to prepare 4 files: 1. __Model config__ (*.xml) - a text file with network configuration. 1. __Model weights__ (*.bin) - a binary file with trained weights. 1. __Label map__ (*.json) - a simple json file with `label_map` dictionary like -object with string values for label numbers. Values in `label_map` should be -exactly equal to labels for the annotation task, otherwise objects with mismatched -labels will be ignored. +object with string values for label numbers. Example: ```json { diff --git a/cvat/apps/auto_annotation/__init__.py b/cvat/apps/auto_annotation/__init__.py index a78eca36..d6d5de7d 100644 --- a/cvat/apps/auto_annotation/__init__.py +++ b/cvat/apps/auto_annotation/__init__.py @@ -3,6 +3,10 @@ # # SPDX-License-Identifier: MIT -from cvat.settings.base import JS_3RDPARTY +from cvat.settings.base import JS_3RDPARTY, CSS_3RDPARTY -JS_3RDPARTY['dashboard'] = JS_3RDPARTY.get('dashboard', []) + ['auto_annotation/js/auto_annotation.js'] +default_app_config = 'cvat.apps.auto_annotation.apps.AutoAnnotationConfig' + +JS_3RDPARTY['dashboard'] = JS_3RDPARTY.get('dashboard', []) + ['auto_annotation/js/dashboardPlugin.js'] + +CSS_3RDPARTY['dashboard'] = CSS_3RDPARTY.get('dashboard', []) + ['auto_annotation/stylesheet.css'] diff --git a/cvat/apps/auto_annotation/apps.py b/cvat/apps/auto_annotation/apps.py index f421e132..cea75abf 100644 --- a/cvat/apps/auto_annotation/apps.py +++ b/cvat/apps/auto_annotation/apps.py @@ -7,5 +7,9 @@ from django.apps import AppConfig class AutoAnnotationConfig(AppConfig): - name = "auto_annotation" + name = "cvat.apps.auto_annotation" + def ready(self): + from .permissions import setup_permissions + + setup_permissions() diff --git a/cvat/apps/auto_annotation/migrations/0001_initial.py b/cvat/apps/auto_annotation/migrations/0001_initial.py new file mode 100644 index 00000000..ebd8a6e1 --- /dev/null +++ b/cvat/apps/auto_annotation/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 2.1.3 on 2019-01-24 14:05 + +import cvat.apps.auto_annotation.models +from django.conf import settings +import django.core.files.storage +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AnnotationModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', cvat.apps.auto_annotation.models.SafeCharField(max_length=256)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now_add=True)), + ('model_file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=cvat.apps.auto_annotation.models.upload_path_handler)), + ('weights_file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=cvat.apps.auto_annotation.models.upload_path_handler)), + ('labelmap_file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=cvat.apps.auto_annotation.models.upload_path_handler)), + ('interpretation_file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=cvat.apps.auto_annotation.models.upload_path_handler)), + ('shared', models.BooleanField(default=False)), + ('primary', models.BooleanField(default=False)), + ('framework', models.CharField(default=cvat.apps.auto_annotation.models.FrameworkChoice('openvino'), max_length=32)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': (), + }, + ), + ] diff --git a/cvat/apps/auto_annotation/model_manager.py b/cvat/apps/auto_annotation/model_manager.py new file mode 100644 index 00000000..a25c153f --- /dev/null +++ b/cvat/apps/auto_annotation/model_manager.py @@ -0,0 +1,143 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import django_rq +import os +import rq +import shutil + +from django.db import transaction +from django.utils import timezone +from django.conf import settings + +from .models import AnnotationModel, FrameworkChoice + +def _remove_old_file(model_file_field): + if model_file_field and os.path.exists(model_file_field.name): + os.remove(model_file_field.name) + +@transaction.atomic +def _update_dl_model_thread(dl_model_id, model_file, weights_file, labelmap_file, interpretation_file, run_tests): + def _get_file_content(filename): + return os.path.basename(filename), open(filename, "rb") + + job = rq.get_current_job() + job.meta["progress"] = "Saving data" + job.save_meta() + + dl_model = AnnotationModel.objects.select_for_update().get(pk=dl_model_id) + + #save files in case of files should be uploaded from share + if model_file: + _remove_old_file(dl_model.model_file) + dl_model.model_file.save(*_get_file_content(model_file)) + if weights_file: + _remove_old_file(dl_model.weights_file) + dl_model.weights_file.save(*_get_file_content(weights_file)) + if labelmap_file: + _remove_old_file(dl_model.labelmap_file) + dl_model.labelmap_file.save(*_get_file_content(labelmap_file)) + if interpretation_file: + _remove_old_file(dl_model.interpretation_file) + dl_model.interpretation_file.save(*_get_file_content(interpretation_file)) + + if run_tests: + #only for testing + import time + time.sleep(3) + job.meta["progress"] = "Test started" + job.save_meta() + time.sleep(5) + job.meta["progress"] = "Test finished" + +@transaction.atomic +def update_model(dl_model_id, name, model_file, weights_file, labelmap_file, interpretation_file, storage, is_shared): + + def get_abs_path(share_path): + if not share_path: + return share_path + share_root = settings.SHARE_ROOT + relpath = os.path.normpath(share_path).lstrip('/') + if '..' in relpath.split(os.path.sep): + raise Exception('Permission denied') + abspath = os.path.abspath(os.path.join(share_root, relpath)) + if os.path.commonprefix([share_root, abspath]) != share_root: + raise Exception('Bad file path on share: ' + abspath) + return abspath + + dl_model = AnnotationModel.objects.select_for_update().get(pk=dl_model_id) + + if name: + dl_model.name = name + + if is_shared != None: + dl_model.shared = is_shared + + run_tests = bool(model_file or weights_file or labelmap_file or interpretation_file) + if storage != "local": + model_file = get_abs_path(model_file) + weights_file = get_abs_path(weights_file) + labelmap_file = get_abs_path(labelmap_file) + interpretation_file = get_abs_path(interpretation_file) + else: + if model_file: + _remove_old_file(dl_model.model_file) + dl_model.model_file = model_file + model_file = None + if weights_file: + _remove_old_file(dl_model.weights_file) + dl_model.weights_file = weights_file + weights_file = None + if labelmap_file: + _remove_old_file(dl_model.labelmap_file) + dl_model.labelmap_file = labelmap_file + labelmap_file = None + if interpretation_file: + _remove_old_file(dl_model.interpretation_file) + dl_model.interpretation_file = interpretation_file + interpretation_file = None + + dl_model.updated_date = timezone.now() + dl_model.save() + + rq_id = "auto_annotation.create.{}".format(dl_model_id) + queue = django_rq.get_queue('default') + queue.enqueue_call( + func = _update_dl_model_thread, + args = (dl_model_id, + model_file, + weights_file, + labelmap_file, + interpretation_file, + run_tests, + ), + job_id = rq_id + ) + + return rq_id + +def create_empty(owner, framework=FrameworkChoice.OPENVINO): + db_model = AnnotationModel( + owner=owner, + ) + db_model.save() + + model_path = db_model.get_dirname() + if os.path.isdir(model_path): + shutil.rmtree(model_path) + os.mkdir(model_path) + + return db_model.id + +@transaction.atomic +def delete(dl_model_id): + dl_model = AnnotationModel.objects.select_for_update().get(pk=dl_model_id) + if dl_model: + if dl_model.primary: + raise Exception("Can not delete primary model {}".format(dl_model_id)) + + dl_model.delete() + shutil.rmtree(dl_model.get_dirname(), ignore_errors=True) + else: + raise Exception("Requested DL model {} doesn't exist".format(dl_model_id)) diff --git a/cvat/apps/auto_annotation/models.py b/cvat/apps/auto_annotation/models.py index a59acdef..72513b10 100644 --- a/cvat/apps/auto_annotation/models.py +++ b/cvat/apps/auto_annotation/models.py @@ -2,3 +2,54 @@ # Copyright (C) 2018 Intel Corporation # # SPDX-License-Identifier: MIT + +from enum import Enum + +from django.db import models +from django.conf import settings +from django.contrib.auth.models import User +from django.core.files.storage import FileSystemStorage + +fs = FileSystemStorage() + +def upload_path_handler(instance, filename): + return "{models_root}/{id}/{file}".format(models_root=settings.MODELS_ROOT, id=instance.id, file=filename) + +class FrameworkChoice(Enum): + OPENVINO = 'openvino' + TENSORFLOW = 'tensorflow' + PYTORCH = 'pytorch' + + def __str__(self): + return self.value + + +class SafeCharField(models.CharField): + def get_prep_value(self, value): + value = super().get_prep_value(value) + if value: + return value[:self.max_length] + return value + +class AnnotationModel(models.Model): + name = SafeCharField(max_length=256) + owner = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL) + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now_add=True) + model_file = models.FileField(upload_to=upload_path_handler, storage=fs) + weights_file = models.FileField(upload_to=upload_path_handler, storage=fs) + labelmap_file = models.FileField(upload_to=upload_path_handler, storage=fs) + interpretation_file = models.FileField(upload_to=upload_path_handler, storage=fs) + shared = models.BooleanField(default=False) + primary = models.BooleanField(default=False) + framework = models.CharField(max_length=32, default=FrameworkChoice.OPENVINO) + + class Meta: + default_permissions = () + + def get_dirname(self): + return "{models_root}/{id}".format(models_root=settings.MODELS_ROOT, id=self.id) + + def __str__(self): + return self.name diff --git a/cvat/apps/auto_annotation/permissions.py b/cvat/apps/auto_annotation/permissions.py new file mode 100644 index 00000000..ede8d611 --- /dev/null +++ b/cvat/apps/auto_annotation/permissions.py @@ -0,0 +1,29 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import rules + +from cvat.apps.authentication.auth import has_admin_role, has_user_role + +@rules.predicate +def is_model_owner(db_user, db_dl_model): + return db_dl_model.owner == db_user + +@rules.predicate +def is_shared_model(_, db_dl_model): + return db_dl_model.shared + +@rules.predicate +def is_primary_model(_, db_dl_model): + return db_dl_model.primary + +def setup_permissions(): + rules.add_perm('auto_annotation.model.create', has_admin_role | has_user_role) + + rules.add_perm('auto_annotation.model.update', (has_admin_role | is_model_owner) & ~is_primary_model) + + rules.add_perm('auto_annotation.model.delete', (has_admin_role | is_model_owner) & ~is_primary_model) + + rules.add_perm('auto_annotation.model.access', has_admin_role | is_model_owner | + is_shared_model | is_primary_model) diff --git a/cvat/apps/auto_annotation/static/auto_annotation/js/auto_annotation.js b/cvat/apps/auto_annotation/static/auto_annotation/js/auto_annotation.js deleted file mode 100644 index 558268b9..00000000 --- a/cvat/apps/auto_annotation/static/auto_annotation/js/auto_annotation.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (C) 2018 Intel Corporation - * - * SPDX-License-Identifier: MIT -*/ - -/* global - showMessage:false -*/ - -"use strict"; - -window.cvat = window.cvat || {}; -window.cvat.dashboard = window.cvat.dashboard || {}; -window.cvat.dashboard.uiCallbacks = window.cvat.dashboard.uiCallbacks || []; -window.cvat.dashboard.uiCallbacks.push(function(newElements) { - let tids = []; - for (let el of newElements) { - tids.push(el.id.split("_")[1]); - } - - $.ajax({ - type: "POST", - url: "/auto_annotation/meta/get", - data: JSON.stringify(tids), - contentType: "application/json; charset=utf-8", - success: (data) => { - newElements.each(function() { - let elem = $(this); - let tid = +elem.attr("id").split("_")[1]; - - const autoAnnoButton = $("").addClass("regular dashboardButtonUI dashboardAutoAnno"); - autoAnnoButton.appendTo(elem.find("div.dashboardButtonsUI")[0]); - - if (tid in data && data[tid].active) { - autoAnnoButton.text("Cancel auto annotation"); - autoAnnoButton.addClass("autoAnnotationProcess"); - window.cvat.autoAnnotation.checkAutoAnnotationRequest(tid, autoAnnoButton); - } - - autoAnnoButton.on("click", () => { - if (autoAnnoButton.hasClass("autoAnnotationProcess")) { - $.post(`/auto_annotation/cancel/task/${tid}`).fail( (data) => { - let message = `Error during cancel auto annotation request. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; - showMessage(message); - throw Error(message); - }); - } - else { - let dialogWindow = $(`#${window.cvat.autoAnnotation.modalWindowId}`); - dialogWindow.attr("current_tid", tid); - dialogWindow.removeClass("hidden"); - } - }); - }); - }, - error: (data) => { - let message = `Can not get auto annotation meta info. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; - window.cvat.autoAnnotation.badResponse(message); - } - }); -}); - -window.cvat.autoAnnotation = { - modalWindowId: "autoAnnotationWindow", - autoAnnoFromId: "autoAnnotationForm", - autoAnnoModelFieldId: "autoAnnotationModelField", - autoAnnoWeightsFieldId: "autoAnnotationWeightsField", - autoAnnoConfigFieldId: "autoAnnotationConfigField", - autoAnnoConvertFieldId: "autoAnnotationConvertField", - autoAnnoCloseButtonId: "autoAnnoCloseButton", - autoAnnoSubmitButtonId: "autoAnnoSubmitButton", - - checkAutoAnnotationRequest: (tid, autoAnnoButton) => { - function timeoutCallback() { - $.get(`/auto_annotation/check/task/${tid}`).done((data) => { - if (data.status === "started" || data.status === "queued") { - let progress = Math.round(data.progress) || 0; - autoAnnoButton.text(`Cancel auto annotation (${progress}%)`); - setTimeout(timeoutCallback, 1000); - } - else { - autoAnnoButton.text("Run auto annotation"); - autoAnnoButton.removeClass("autoAnnotationProcess"); - } - }).fail((data) => { - let message = "Error was occurred during check annotation status. " + - `Code: ${data.status}, text: ${data.responseText || data.statusText}`; - window.cvat.autoAnnotation.badResponse(message); - }); - } - setTimeout(timeoutCallback, 1000); - }, - - badResponse(message) { - showMessage(message); - throw Error(message); - }, -}; - -function submitButtonOnClick() { - const annoWindow = $(`#${window.cvat.autoAnnotation.modalWindowId}`); - const tid = annoWindow.attr("current_tid"); - const modelInput = $(`#${window.cvat.autoAnnotation.autoAnnoModelFieldId}`); - const weightsInput = $(`#${window.cvat.autoAnnotation.autoAnnoWeightsFieldId}`); - const configInput = $(`#${window.cvat.autoAnnotation.autoAnnoConfigFieldId}`); - const convFileInput = $(`#${window.cvat.autoAnnotation.autoAnnoConvertFieldId}`); - - const modelFile = modelInput.prop("files")[0]; - const weightsFile = weightsInput.prop("files")[0]; - const configFile = configInput.prop("files")[0]; - const convFile = convFileInput.prop("files")[0]; - - if (!modelFile || !weightsFile || !configFile || !convFile) { - showMessage("All files must be selected"); - return; - } - - let taskData = new FormData(); - taskData.append("model", modelFile); - taskData.append("weights", weightsFile); - taskData.append("config", configFile); - taskData.append("conv_script", convFile); - - $.ajax({ - url: `/auto_annotation/create/task/${tid}`, - type: "POST", - data: taskData, - contentType: false, - processData: false, - }).done(() => { - annoWindow.addClass("hidden"); - const autoAnnoButton = $(`#dashboardTask_${tid} div.dashboardButtonsUI button.dashboardAutoAnno`); - autoAnnoButton.addClass("autoAnnotationProcess"); - window.cvat.autoAnnotation.checkAutoAnnotationRequest(tid, autoAnnoButton); - }).fail((data) => { - let message = "Error was occurred during run annotation request. " + - `Code: ${data.status}, text: ${data.responseText || data.statusText}`; - window.cvat.autoAnnotation.badResponse(message); - }); -} - -document.addEventListener("DOMContentLoaded", () => { - $(``).appendTo("body"); - - const annoWindow = $(`#${window.cvat.autoAnnotation.modalWindowId}`); - const closeWindowButton = $(`#${window.cvat.autoAnnotation.autoAnnoCloseButtonId}`); - const submitButton = $(`#${window.cvat.autoAnnotation.autoAnnoSubmitButtonId}`); - - - closeWindowButton.on("click", () => { - annoWindow.addClass("hidden"); - }); - - submitButton.on("click", () => { - submitButtonOnClick(); - }); -}); diff --git a/cvat/apps/auto_annotation/static/auto_annotation/js/dashboardPlugin.js b/cvat/apps/auto_annotation/static/auto_annotation/js/dashboardPlugin.js new file mode 100644 index 00000000..82e3a6f2 --- /dev/null +++ b/cvat/apps/auto_annotation/static/auto_annotation/js/dashboardPlugin.js @@ -0,0 +1,792 @@ +/* + * Copyright (C) 2018 Intel Corporation + * + * SPDX-License-Identifier: MIT +*/ + +/* global + showMessage + showOverlay + userConfirm +*/ + +window.cvat = window.cvat || {}; +window.cvat.dashboard = window.cvat.dashboard || {}; +window.cvat.dashboard.uiCallbacks = window.cvat.dashboard.uiCallbacks || []; + +const AutoAnnotationServer = { + start(modelId, taskId, data, success, error, progress, check) { + $.ajax({ + url: `/auto_annotation/start/${modelId}/${taskId}`, + type: 'POST', + data: JSON.stringify(data), + contentType: 'application/json', + success: (responseData) => { + check(responseData.id, success, error, progress); + }, + error: (responseData) => { + const message = `Starting request has been failed. Code: ${responseData.status}. Message: ${responseData.responseText || responseData.statusText}`; + error(message); + }, + }); + }, + + update(data, success, error, progress, check, modelId) { + let url = ''; + if (modelId === null) { + url = '/auto_annotation/create'; + } else { + url = `/auto_annotation/update/${modelId}`; + } + + $.ajax({ + url, + type: 'POST', + data, + contentType: false, + processData: false, + success: (responseData) => { + check(responseData.id, success, error, progress); + }, + error: (responseData) => { + const message = `Creating request has been failed. Code: ${responseData.status}. Message: ${responseData.responseText || responseData.statusText}`; + error(message); + }, + }); + }, + + delete(modelId, success, error) { + $.ajax({ + url: `/auto_annotation/delete/${modelId}`, + type: 'DELETE', + success, + error: (data) => { + const message = `Deleting request has been failed. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; + error(message); + }, + }); + }, + + check(workerId, success, error, progress) { + function updateProgress(data) { + if (data.progress && progress) { + progress(data.progress); + } + } + + function checkCallback() { + $.ajax({ + url: `/auto_annotation/check/${workerId}`, + type: 'GET', + success: (data) => { + updateProgress(data, progress); + + switch (data.status) { + case 'failed': + error(`Checking request has returned the "${data.status}" status. Message: ${data.error}`); + break; + + case 'unknown': + error(`Checking request has returned the "${data.status}" status.`); + break; + + case 'finished': + success(); + break; + + default: + setTimeout(checkCallback, 1000); + } + }, + error: (data) => { + const message = `Checking request has been failed. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; + error(message); + }, + }); + } + + setTimeout(checkCallback, 1000); + }, + + meta(tids, success, error) { + $.ajax({ + url: '/auto_annotation/meta/get', + type: 'POST', + data: JSON.stringify(tids), + contentType: 'application/json', + success, + error: (data) => { + const message = `Getting meta request has been failed. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; + error(message); + }, + }); + }, + + cancel(tid, success, error) { + $.ajax({ + url: `/auto_annotation/cancel/${tid}`, + type: 'GET', + success, + error: (data) => { + const message = `Getting meta request has been failed. Code: ${data.status}. Message: ${data.responseText || data.statusText}`; + error(message); + }, + }); + }, +}; + + +class AutoAnnotationModelManagerView { + constructor() { + const html = `