6
0
Pārlūkot izejas kodu

Bounding box confirmation, logout button moved, description of pipeline annotations

Annotations can be confirmed by multiple users.
Pipeline-generated annotations are described as "Annotated by: pipeline".
Logout button was moved to top navigation bar.
Cropped images can no longer overflow in the direction of the y-axis.
blunk 3 gadi atpakaļ
vecāks
revīzija
7972665fef

+ 7 - 0
migrations/versions/b03df3e31b8d_.py

@@ -109,6 +109,13 @@ def upgrade():
     sa.ForeignKeyConstraint(['label_id'], ['label.id'], ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id')
     )
+    op.create_table('result_confirmation',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('result_id', sa.Integer(), nullable=False),
+    sa.Column('confirming_user', sa.String(), nullable=False),
+    sa.ForeignKeyConstraint(['result_id'], ['result.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
     # ### end Alembic commands ###
 
 

+ 1 - 1
pycs/database/File.py

@@ -222,7 +222,7 @@ class File(NamedBaseModel):
             if isinstance(label, Label):
                 label = label.id
 
-            result.label_id = label
+            result.set_label(label)
 
         return result
 

+ 88 - 1
pycs/database/Result.py

@@ -5,6 +5,21 @@ from pycs import db
 from pycs.database.base import BaseModel
 from pycs.database.util import commit_on_return
 
+class ResultConfirmation(BaseModel):
+    """ DB Model for user confirmations of results """
+
+    result_id = db.Column(
+        db.Integer,
+        db.ForeignKey("result.id", ondelete="CASCADE"),
+        nullable=False)
+
+    confirming_user = db.Column(db.String, nullable=False)
+
+    serialize_only = BaseModel.serialize_only + (
+        "result_id",
+        "confirming_user",
+    )
+
 class Result(BaseModel):
     """ DB Model for projects """
 
@@ -24,6 +39,12 @@ class Result(BaseModel):
 
     data_encoded = db.Column(db.String)
 
+    result_confirmations = db.relationship("ResultConfirmation",
+        backref="result",
+        lazy="dynamic",
+        passive_deletes=True,
+    )
+
     serialize_only = BaseModel.serialize_only + (
         "file_id",
         "origin",
@@ -31,12 +52,15 @@ class Result(BaseModel):
         "type",
         "label_id",
         "data",
+        "confirmations"
     )
 
     def serialize(self):
         """ extends the default serialize with the decoded data attribute """
         result = super().serialize()
         result["data"] = self.data
+        result["confirmations"] = self.confirmations
+
         return result
 
     @property
@@ -74,6 +98,7 @@ class Result(BaseModel):
 
         self.origin = origin
         self.origin_user = origin_user
+        self.reset_confirmations()
 
     @commit_on_return
     def set_label(self, label: int):
@@ -83,4 +108,66 @@ class Result(BaseModel):
         :param label: label ID
         :return:
         """
-        self.label_id = label
+        if self.label_id != label:
+            self.reset_confirmations()
+            self.label_id = label
+
+    @property
+    def confirmations(self) -> T.List[ResultConfirmation]:
+        """
+            Returns all confirmations for this results
+
+            :return: list of result confirmations
+        """
+
+        confirmations = db.session.query(ResultConfirmation).filter(
+                            ResultConfirmation.result.has(Result.id==self.id))
+        _confirmations = [c.serialize() for c in confirmations.all()]
+
+        _confirmations = [{k:v for k, v in c.items()
+                            if k in ('id', 'confirming_user')}
+                            for c in _confirmations]
+
+        return _confirmations
+
+    def reset_confirmations(self) -> T.List[ResultConfirmation]:
+        """
+        Resets all confirmations
+
+        :return: list of result confirmation objects
+        """
+        confirmations = ResultConfirmation.query.filter(
+                ResultConfirmation.result_id == self.id)
+
+        _confirmations = [c.serialize() for c in confirmations.all()]
+        confirmations.delete()
+
+        return _confirmations
+
+    @commit_on_return
+    def confirm(self, user: str):
+        """
+            Result is confirmed by the given user. This sets the origin to "user".
+            If no username was specified before, the given username is used.
+            A confirmation is only added if it does not already exist. The result
+            has be labeled to be confirmed.
+
+            :param user: username
+        """
+        if user is None:
+            raise ValueError("When confirming a result the username has to" \
+                            "be specified.")
+
+        if self.origin == "pipeline":
+            self.set_origin(origin="user", origin_user=user)
+
+        # Get current confirmations.
+        confirmations = self.confirmations
+
+        # Results can only be confirmed if the result is labeled.
+        # Also, the original annotator cannot confirm the result and we want
+        # to avoid duplicates.
+        if self.label_id is not None and self.origin_user != user and not len(confirmations) > 0:
+            ResultConfirmation.new(commit=False,
+                                   result_id=self.id,
+                                   confirming_user=user)

+ 1 - 2
pycs/frontend/endpoints/results/ConfirmResult.py

@@ -27,8 +27,7 @@ class ConfirmResult(View):
         if not data.get('confirm', False):
             return abort(400, "confirm flag is missing")
 
-
-        result.set_origin('user', origin_user=user)
+        result.confirm(user)
 
         self.nm.edit_result(result)
         return make_response()

+ 1 - 1
pycs/frontend/endpoints/results/CreateResult.py

@@ -73,6 +73,6 @@ class CreateResult(View):
                 label=label,
                 data=data)
 
-            self.nm.create_result(result)
+        self.nm.create_result(result)
 
         return jsonify(result)

+ 1 - 1
pycs/frontend/endpoints/results/EditResultLabel.py

@@ -34,7 +34,7 @@ class EditResultLabel(View):
         if result.type == 'labeled-image' and label is None:
             abort(400, "Label is required for 'labeled-images' results")
 
-        result.label_id = label
+        result.set_label(label)
         result.set_origin('user', origin_user=user, commit=True)
 
         self.nm.edit_result(result)

+ 46 - 5
webui/src/components/media/cropped-image.vue

@@ -19,11 +19,28 @@
       </div>
     </div>
 
-    <div v-if="src" class="image-container">
-      <img alt="crop" :src="src"/>
+    <div v-if="src">
+      <div class="image-container">
+        <img alt="crop" :src="src" />
+      </div>
 
-      <div v-if="this.box.origin === 'user' && this.box.origin_user !== null">
-        Annotated by: <span style="color:red">{{this.box.origin_user}}</span>
+      <div v-if="getAnnotator() !== ''" class="annotation-info">
+        <table border="0">
+          <tr>
+            <td>Annotated by:</td>
+            <td align="left"><b>{{getAnnotator()}}</b></td>
+          </tr>
+          <tr v-if="box.confirmations.length !== 0">
+            <td>Confirmed by:</td>
+            <td>
+              <ul class="confirming-users">
+                <li v-for="confirmation in box.confirmations" :key="confirmation.id" margin="0" padding="0">
+                  <b>{{confirmation.confirming_user}}</b>
+                </li>
+              </ul>
+            </td>
+          </tr>
+        </table>
       </div>
     </div>
 
@@ -122,7 +139,16 @@ export default {
         }
       }
     },
-    predict_cropped_image: function () {
+    getAnnotator: function () {
+      if (this.box.origin === 'user' && this.box.origin_user !== null) {
+        return this.box.origin_user;
+      }
+      if (this.box.origin === 'pipeline') {
+        return 'pipeline';
+      }
+      return '';
+
+    }, predict_cropped_image: function () {
       // This shouldn't happen, since the icon is only shown if a bounding box
       // was selected.
       if (!this.box)
@@ -165,6 +191,7 @@ export default {
 .image-container img {
   border: 2px solid;
   max-width: 100%;
+  max-height: 50vh;
 }
 
 .label-container {
@@ -200,4 +227,18 @@ export default {
   filter: invert(1);
 }
 
+.annotation-info {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow-x: auto;
+  max-height: 13vh;
+}
+
+.confirming-users {
+  list-style-position: inside;
+  padding: 0;
+  margin:0;
+}
+
 </style>

+ 0 - 6
webui/src/components/window/side-navigation-bar.vue

@@ -35,12 +35,6 @@
         <span>Session</span>
       </div>
 
-      <div class="item"
-           @click="$root.socket.logout()">
-        <img alt="Logout" src="@/assets/icons/chevron-left.svg">
-        <span>Logout</span>
-      </div>
-
       <div class="item"
            :class="{active: window.content === 'about'}"
            @click="show('about')">

+ 23 - 6
webui/src/components/window/top-navigation-bar.vue

@@ -14,6 +14,16 @@
       {{ title }}
     </div>
 
+    <!--- Spacer -->
+    <div class="spacer" />
+
+    <div v-if="$root.socket.authenticated">
+      <div class="logout"
+           @click="$root.socket.logout()">
+         Logout ({{$root.socket.username}})
+      </div>
+    </div>
+
     <div class="jobs" :class="{colored: show}" @click="showJobs">
       <template v-if="failedJobCount > 0">
         {{ failedJobCount }} {{ failedJobCount === 1 ? 'job' : 'jobs' }} failed
@@ -161,11 +171,6 @@ export default {
   font-weight: bold;
   font-family: "Roboto Condensed", sans-serif;
   padding: 1rem;
-  flex-grow: 1;
-
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  overflow-x: hidden;
 }
 
 .name.project {
@@ -180,6 +185,18 @@ export default {
   display: none;
 }
 
+.spacer {
+  flex-grow: 1;
+
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow-x: hidden;
+}
+
+.logout {
+  cursor: pointer;
+}
+
 .jobs {
   font-family: "Roboto Condensed", sans-serif;
   white-space: nowrap;
@@ -190,4 +207,4 @@ export default {
 .jobs.colored {
   color: var(--secondary);
 }
-</style>
+</style>