geovisio 2.8.1__py3-none-any.whl → 2.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geovisio/__init__.py +6 -1
- geovisio/config_app.py +16 -5
- geovisio/translations/ar/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ar/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/br/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/da/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/da/LC_MESSAGES/messages.po +4 -3
- geovisio/translations/de/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/de/LC_MESSAGES/messages.po +55 -2
- geovisio/translations/el/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/en/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/en/LC_MESSAGES/messages.po +193 -139
- geovisio/translations/eo/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/eo/LC_MESSAGES/messages.po +53 -4
- geovisio/translations/es/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fi/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/fr/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/fr/LC_MESSAGES/messages.po +101 -6
- geovisio/translations/hu/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/it/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/it/LC_MESSAGES/messages.po +63 -3
- geovisio/translations/ja/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/ko/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/messages.pot +185 -129
- geovisio/translations/nl/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/nl/LC_MESSAGES/messages.po +421 -86
- geovisio/translations/oc/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/oc/LC_MESSAGES/messages.po +818 -0
- geovisio/translations/pl/LC_MESSAGES/messages.po +1 -1
- geovisio/translations/sv/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/sv/LC_MESSAGES/messages.po +823 -0
- geovisio/translations/ti/LC_MESSAGES/messages.mo +0 -0
- geovisio/translations/ti/LC_MESSAGES/messages.po +762 -0
- geovisio/translations/zh_Hant/LC_MESSAGES/messages.po +1 -1
- geovisio/utils/annotations.py +183 -0
- geovisio/utils/auth.py +14 -13
- geovisio/utils/cql2.py +134 -0
- geovisio/utils/db.py +7 -0
- geovisio/utils/fields.py +38 -9
- geovisio/utils/items.py +44 -0
- geovisio/utils/model_query.py +4 -4
- geovisio/utils/pic_shape.py +63 -0
- geovisio/utils/pictures.py +164 -29
- geovisio/utils/reports.py +10 -17
- geovisio/utils/semantics.py +196 -57
- geovisio/utils/sentry.py +1 -2
- geovisio/utils/sequences.py +191 -93
- geovisio/utils/tags.py +31 -0
- geovisio/utils/upload_set.py +287 -209
- geovisio/utils/website.py +1 -1
- geovisio/web/annotations.py +346 -9
- geovisio/web/auth.py +1 -1
- geovisio/web/collections.py +73 -54
- geovisio/web/configuration.py +26 -5
- geovisio/web/docs.py +143 -11
- geovisio/web/items.py +232 -155
- geovisio/web/map.py +25 -13
- geovisio/web/params.py +55 -52
- geovisio/web/pictures.py +34 -0
- geovisio/web/stac.py +19 -12
- geovisio/web/tokens.py +49 -1
- geovisio/web/upload_set.py +148 -37
- geovisio/web/users.py +4 -4
- geovisio/web/utils.py +2 -2
- geovisio/workers/runner_pictures.py +190 -24
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/METADATA +27 -26
- geovisio-2.10.0.dist-info/RECORD +105 -0
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/WHEEL +1 -1
- geovisio-2.8.1.dist-info/RECORD +0 -92
- {geovisio-2.8.1.dist-info → geovisio-2.10.0.dist-info}/licenses/LICENSE +0 -0
geovisio/utils/pictures.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import math
|
|
2
|
-
from typing import Dict, Optional
|
|
3
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
3
4
|
from uuid import UUID
|
|
5
|
+
from attr import dataclass
|
|
4
6
|
from flask import current_app, redirect, send_file
|
|
5
7
|
from flask_babel import gettext as _
|
|
6
8
|
import os
|
|
@@ -13,14 +15,28 @@ import logging
|
|
|
13
15
|
from dataclasses import asdict
|
|
14
16
|
from fs.path import dirname
|
|
15
17
|
from psycopg.errors import UniqueViolation, InvalidParameterValue
|
|
18
|
+
from psycopg.types.json import Jsonb
|
|
19
|
+
from psycopg import sql, Connection
|
|
20
|
+
import sentry_sdk
|
|
16
21
|
from geovisio import utils, errors
|
|
17
22
|
from geopic_tag_reader import reader
|
|
18
23
|
import re
|
|
24
|
+
import multipart
|
|
25
|
+
|
|
26
|
+
from geovisio.utils import db
|
|
19
27
|
|
|
20
28
|
log = logging.getLogger(__name__)
|
|
21
29
|
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
@dataclass
|
|
32
|
+
class BlurredPicture:
|
|
33
|
+
"""Blurred picture's response"""
|
|
34
|
+
|
|
35
|
+
image: Image
|
|
36
|
+
metadata: Dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename, keep_unblured_parts=False) -> Optional[BlurredPicture]:
|
|
24
40
|
"""Create the blurred version of a picture using a blurMask
|
|
25
41
|
|
|
26
42
|
Parameters
|
|
@@ -39,20 +55,49 @@ def createBlurredHDPicture(fs, blurApi, pictureBytes, outputFilename):
|
|
|
39
55
|
PIL.Image
|
|
40
56
|
The blurred version of the image
|
|
41
57
|
"""
|
|
58
|
+
if blurApi is None:
|
|
59
|
+
return None
|
|
60
|
+
# Call blur API, asking for multipart response if available
|
|
61
|
+
pictureBytes.seek(0)
|
|
62
|
+
query_params = {"keep": 1} if keep_unblured_parts else {}
|
|
63
|
+
blurResponse = requests.post(
|
|
64
|
+
f"{blurApi}/blur/",
|
|
65
|
+
files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")},
|
|
66
|
+
headers={"Accept": "multipart/form-data"},
|
|
67
|
+
params=query_params,
|
|
68
|
+
)
|
|
69
|
+
blurResponse.raise_for_status()
|
|
70
|
+
|
|
71
|
+
metadata, blurred_pic = None, None
|
|
72
|
+
content_type, content_type_params = multipart.parse_options_header(blurResponse.headers.get("content-type", ""))
|
|
73
|
+
if content_type == "multipart/form-data":
|
|
74
|
+
# New blurring api can return multipart response, with separated blurring picture/metadata
|
|
75
|
+
multipart_response = multipart.MultipartParser(io.BytesIO(blurResponse.content), boundary=content_type_params["boundary"])
|
|
76
|
+
|
|
77
|
+
metadata = multipart_response.get("metadata")
|
|
78
|
+
if metadata:
|
|
79
|
+
metadata = metadata.raw
|
|
80
|
+
blurred_pic = multipart_response.get("image")
|
|
81
|
+
if blurred_pic:
|
|
82
|
+
blurred_pic = blurred_pic.raw
|
|
83
|
+
else:
|
|
84
|
+
# old blurring API, no multipart response, we read the `x-sgblur` header
|
|
85
|
+
blurred_pic = blurResponse.content
|
|
86
|
+
metadata = blurResponse.headers.get("x-sgblur")
|
|
42
87
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
pictureBytes.seek(0)
|
|
46
|
-
blurResponse = requests.post(blurApi + "/blur/", files={"picture": ("picture.jpg", pictureBytes.read(), "image/jpeg")})
|
|
47
|
-
blurResponse.raise_for_status()
|
|
48
|
-
|
|
49
|
-
# Save mask to FS
|
|
50
|
-
fs.writebytes(outputFilename, blurResponse.content)
|
|
88
|
+
# Save mask to FS
|
|
89
|
+
fs.writebytes(outputFilename, blurred_pic)
|
|
51
90
|
|
|
52
|
-
|
|
91
|
+
if metadata:
|
|
92
|
+
try:
|
|
93
|
+
metadata = json.loads(metadata)
|
|
94
|
+
except (json.decoder.JSONDecodeError, TypeError) as e:
|
|
95
|
+
# we skip the metadata's response if we are not able to understand it
|
|
96
|
+
log.warning(f"Impossible to parse blurring metadata API response: {e}")
|
|
97
|
+
sentry_sdk.capture_exception(e)
|
|
98
|
+
metadata = None
|
|
53
99
|
|
|
54
|
-
|
|
55
|
-
return None
|
|
100
|
+
return BlurredPicture(image=Image.open(io.BytesIO(blurred_pic)), metadata=metadata)
|
|
56
101
|
|
|
57
102
|
|
|
58
103
|
def getTileSize(imgSize):
|
|
@@ -511,6 +556,25 @@ class MetadataReadingError(Exception):
|
|
|
511
556
|
self.missing_mandatory_tags = missing_mandatory_tags
|
|
512
557
|
|
|
513
558
|
|
|
559
|
+
def get_lighter_metadata(metadata):
|
|
560
|
+
"""Create a lighter metadata field to remove duplicates fields"""
|
|
561
|
+
lighterMetadata = dict(
|
|
562
|
+
filter(
|
|
563
|
+
lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source", "gps_accuracy"],
|
|
564
|
+
metadata.items(),
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
|
|
568
|
+
del lighterMetadata["tagreader_warnings"]
|
|
569
|
+
lighterMetadata["tz"] = metadata["ts"].tzname()
|
|
570
|
+
if metadata.get("ts_by_source", {}).get("gps") is not None:
|
|
571
|
+
lighterMetadata["ts_gps"] = metadata["ts_by_source"]["gps"].isoformat()
|
|
572
|
+
if metadata.get("ts_by_source", {}).get("camera") is not None:
|
|
573
|
+
lighterMetadata["ts_camera"] = metadata["ts_by_source"]["camera"].isoformat()
|
|
574
|
+
|
|
575
|
+
return lighterMetadata
|
|
576
|
+
|
|
577
|
+
|
|
514
578
|
def insertNewPictureInDatabase(
|
|
515
579
|
db, sequenceId, position, pictureBytes, associatedAccountID, additionalMetadata, uploadSetID=None, lang="en"
|
|
516
580
|
):
|
|
@@ -537,11 +601,10 @@ def insertNewPictureInDatabase(
|
|
|
537
601
|
-------
|
|
538
602
|
uuid : The uuid of the new picture entry in the database
|
|
539
603
|
"""
|
|
540
|
-
from psycopg.types.json import Jsonb
|
|
541
604
|
|
|
542
605
|
# Create a fully-featured metadata object
|
|
543
|
-
|
|
544
|
-
|
|
606
|
+
with Image.open(io.BytesIO(pictureBytes)) as picturePillow:
|
|
607
|
+
metadata = readPictureMetadata(pictureBytes, lang) | utils.pictures.getPictureSizing(picturePillow) | additionalMetadata
|
|
545
608
|
|
|
546
609
|
# Remove cols/rows information for flat pictures
|
|
547
610
|
if metadata["type"] == "flat":
|
|
@@ -549,19 +612,7 @@ def insertNewPictureInDatabase(
|
|
|
549
612
|
metadata.pop("rows")
|
|
550
613
|
|
|
551
614
|
# Create a lighter metadata field to remove duplicates fields
|
|
552
|
-
lighterMetadata =
|
|
553
|
-
filter(
|
|
554
|
-
lambda v: v[0] not in ["ts", "heading", "lon", "lat", "exif", "originalContentMd5", "ts_by_source", "gps_accuracy"],
|
|
555
|
-
metadata.items(),
|
|
556
|
-
)
|
|
557
|
-
)
|
|
558
|
-
if lighterMetadata.get("tagreader_warnings") is not None and len(lighterMetadata["tagreader_warnings"]) == 0:
|
|
559
|
-
del lighterMetadata["tagreader_warnings"]
|
|
560
|
-
lighterMetadata["tz"] = metadata["ts"].tzname()
|
|
561
|
-
if metadata.get("ts_by_source", {}).get("gps") is not None:
|
|
562
|
-
lighterMetadata["ts_gps"] = metadata["ts_by_source"]["gps"].isoformat()
|
|
563
|
-
if metadata.get("ts_by_source", {}).get("camera") is not None:
|
|
564
|
-
lighterMetadata["ts_camera"] = metadata["ts_by_source"]["camera"].isoformat()
|
|
615
|
+
lighterMetadata = get_lighter_metadata(metadata)
|
|
565
616
|
|
|
566
617
|
exif = cleanupExif(metadata["exif"])
|
|
567
618
|
|
|
@@ -597,6 +648,90 @@ def insertNewPictureInDatabase(
|
|
|
597
648
|
return picId
|
|
598
649
|
|
|
599
650
|
|
|
651
|
+
def _get_metadata_to_update(db_picture: Dict, new_reader_metadata: reader.GeoPicTags) -> Tuple[List[str], Dict[str, Any]]:
|
|
652
|
+
fields_to_update = []
|
|
653
|
+
params = {}
|
|
654
|
+
|
|
655
|
+
if new_reader_metadata.ts != db_picture["ts"]:
|
|
656
|
+
fields_to_update.append(sql.SQL("ts = %(ts)s"))
|
|
657
|
+
params["ts"] = new_reader_metadata.ts.isoformat()
|
|
658
|
+
if db_picture["heading_computed"] is False and new_reader_metadata.heading != db_picture["heading"]:
|
|
659
|
+
fields_to_update.append(sql.SQL("heading = %(heading)s"))
|
|
660
|
+
params["heading"] = new_reader_metadata.heading
|
|
661
|
+
if new_reader_metadata.gps_accuracy != db_picture["gps_accuracy_m"]:
|
|
662
|
+
fields_to_update.append(sql.SQL("gps_accuracy_m = %(gps_accuracy_m)s"))
|
|
663
|
+
params["gps_accuracy_m"] = new_reader_metadata.gps_accuracy
|
|
664
|
+
|
|
665
|
+
# Note: The db metadata can have more stuff (like originalFileName, size, ...), we so only check if the new value is different from the old one
|
|
666
|
+
# we cannot check directly for dict equality
|
|
667
|
+
new_lighterMetadata = get_lighter_metadata(asdict(new_reader_metadata))
|
|
668
|
+
metadata_updates = {}
|
|
669
|
+
for k, v in new_lighterMetadata.items():
|
|
670
|
+
if v != db_picture["metadata"].get(k):
|
|
671
|
+
metadata_updates[k] = v
|
|
672
|
+
|
|
673
|
+
# if the position has been updated (by more than ~10cm)
|
|
674
|
+
lon, lat = db_picture["lon"], db_picture["lat"]
|
|
675
|
+
new_lon, new_lat = new_reader_metadata.lon, new_reader_metadata.lat
|
|
676
|
+
if not math.isclose(lon, new_lon, abs_tol=0.0000001) or not math.isclose(lat, new_lat, abs_tol=0.0000001):
|
|
677
|
+
fields_to_update.append(sql.SQL("geom = ST_SetSRID(ST_MakePoint(%(lon)s, %(lat)s), 4326)"))
|
|
678
|
+
params["lon"] = new_reader_metadata.lon
|
|
679
|
+
params["lat"] = new_reader_metadata.lat
|
|
680
|
+
|
|
681
|
+
if metadata_updates:
|
|
682
|
+
fields_to_update.append(sql.SQL("metadata = metadata || %(new_metadata)s"))
|
|
683
|
+
params["new_metadata"] = Jsonb(metadata_updates)
|
|
684
|
+
|
|
685
|
+
return fields_to_update, params
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def ask_for_metadata_update(picture_id: UUID, read_file=False):
|
|
689
|
+
"""Enqueue an async job to reread the picture's metadata"""
|
|
690
|
+
args = Jsonb({"read_file": True}) if read_file else None
|
|
691
|
+
with db.conn(current_app) as conn:
|
|
692
|
+
conn.execute(
|
|
693
|
+
"INSERT INTO job_queue(picture_id, task, args) VALUES (%s, 'read_metadata', %s)",
|
|
694
|
+
[picture_id, args],
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def update_picture_metadata(conn: Connection, picture_id: UUID, read_file=False) -> bool:
|
|
699
|
+
"""Update picture metadata in database, using either the stored metadata or the original file
|
|
700
|
+
|
|
701
|
+
Only updates metadata that have changed.
|
|
702
|
+
Returns True if some metadata have been updated, False otherwise
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
706
|
+
db_picture = cursor.execute(
|
|
707
|
+
"SELECT ts, heading, metadata, ST_X(geom) as lon, ST_Y(geom) as lat, account_id, exif, gps_accuracy_m, heading_computed FROM pictures WHERE id = %s",
|
|
708
|
+
[picture_id],
|
|
709
|
+
).fetchone()
|
|
710
|
+
if db_picture is None:
|
|
711
|
+
raise Exception(f"Picture {picture_id} not found")
|
|
712
|
+
|
|
713
|
+
if read_file:
|
|
714
|
+
pic_path = getHDPicturePath(picture_id)
|
|
715
|
+
|
|
716
|
+
with current_app.config["FILESYSTEMS"].permanent.openbin(pic_path) as picture_bytes:
|
|
717
|
+
new_metadata = reader.readPictureMetadata(picture_bytes.read())
|
|
718
|
+
else:
|
|
719
|
+
new_metadata = reader.getPictureMetadata(db_picture["exif"], db_picture["metadata"]["width"], db_picture["metadata"]["height"])
|
|
720
|
+
|
|
721
|
+
# we want to only updates values that have changed
|
|
722
|
+
fields_to_update, params = _get_metadata_to_update(db_picture, new_metadata)
|
|
723
|
+
|
|
724
|
+
if not fields_to_update:
|
|
725
|
+
logging.debug(f"No metadata update needed for picture {picture_id}")
|
|
726
|
+
return False
|
|
727
|
+
|
|
728
|
+
conn.execute(
|
|
729
|
+
sql.SQL("UPDATE pictures SET {f} WHERE id = %(pic_id)s").format(f=sql.SQL(", ").join(fields_to_update)),
|
|
730
|
+
params | {"pic_id": picture_id},
|
|
731
|
+
)
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
|
|
600
735
|
# Note: we don't want to store and expose exif binary fields as they are difficult to use and take a lot of storage in the database (~20% for maker notes only)
|
|
601
736
|
# This list has been queried from real data (cf [this comment](https://gitlab.com/panoramax/server/api/-/merge_requests/241#note_1790580636)).
|
|
602
737
|
# Update this list (and do a sql migration) if new binary fields are added
|
geovisio/utils/reports.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Optional, List
|
|
|
4
4
|
from typing_extensions import Self
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from pydantic import BaseModel, ConfigDict
|
|
7
|
-
from geovisio.utils import db
|
|
7
|
+
from geovisio.utils import cql2, db
|
|
8
8
|
from geovisio.errors import InvalidAPIUsage
|
|
9
9
|
from flask import current_app
|
|
10
10
|
from psycopg.sql import SQL
|
|
@@ -103,6 +103,13 @@ def is_picture_owner(report: Report, account_id: UUID):
|
|
|
103
103
|
return isOwner
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
REPORT_FILTER_TO_DB_FIELDS = {
|
|
107
|
+
"status": "r.status",
|
|
108
|
+
"reporter": "reporter_account_id",
|
|
109
|
+
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
106
113
|
def _parse_filter(filter: Optional[str]) -> SQL:
|
|
107
114
|
"""
|
|
108
115
|
Parse a filter string and return a SQL expression
|
|
@@ -124,22 +131,8 @@ def _parse_filter(filter: Optional[str]) -> SQL:
|
|
|
124
131
|
"""
|
|
125
132
|
if not filter:
|
|
126
133
|
return SQL("TRUE")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try:
|
|
131
|
-
filterAst = ecql_parser(filter)
|
|
132
|
-
fieldsToFilter = {
|
|
133
|
-
"status": "r.status",
|
|
134
|
-
"reporter": "reporter_account_id",
|
|
135
|
-
"owner": "COALESCE(p.account_id, s.account_id)",
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
f = to_sql_where(filterAst, fieldsToFilter).replace('"', "").replace("'me'", "%(account_id)s") # type: ignore
|
|
139
|
-
return SQL(f) # type: ignore
|
|
140
|
-
except Exception as e:
|
|
141
|
-
print(e)
|
|
142
|
-
raise InvalidAPIUsage(_("Unsupported filter parameter"), status_code=400)
|
|
134
|
+
|
|
135
|
+
return cql2.parse_cql2_filter(filter, REPORT_FILTER_TO_DB_FIELDS)
|
|
143
136
|
|
|
144
137
|
|
|
145
138
|
def list_reports(account_id: UUID, limit: int = 100, filter: Optional[str] = None, forceAccount: bool = True) -> Reports:
|
geovisio/utils/semantics.py
CHANGED
|
@@ -1,42 +1,20 @@
|
|
|
1
|
+
from ast import Dict
|
|
2
|
+
from collections import defaultdict
|
|
1
3
|
from dataclasses import dataclass
|
|
4
|
+
import re
|
|
2
5
|
from uuid import UUID
|
|
3
|
-
from psycopg import Cursor
|
|
4
|
-
from psycopg.sql import SQL, Identifier
|
|
6
|
+
from psycopg import Connection, Cursor
|
|
7
|
+
from psycopg.sql import SQL, Identifier, Placeholder
|
|
5
8
|
from psycopg.types.json import Jsonb
|
|
6
9
|
from psycopg.errors import UniqueViolation
|
|
7
|
-
from
|
|
8
|
-
from typing import List
|
|
10
|
+
from psycopg.rows import dict_row
|
|
11
|
+
from typing import Generator, List, Optional
|
|
9
12
|
from enum import Enum
|
|
10
13
|
|
|
11
14
|
from geovisio import errors
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"""Actions to perform on a tag list"""
|
|
16
|
-
|
|
17
|
-
add = "add"
|
|
18
|
-
delete = "delete"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class SemanticTagUpdate(BaseModel):
|
|
22
|
-
"""Parameters used to update a tag list"""
|
|
23
|
-
|
|
24
|
-
action: TagAction = Field(default=TagAction.add)
|
|
25
|
-
"""Action to perform on the tag list. The default action is `add` which will add the given tag to the list.
|
|
26
|
-
The action can also be to `delete` the key/value"""
|
|
27
|
-
key: str = Field(max_length=256)
|
|
28
|
-
"""Key of the tag to update limited to 256 characters"""
|
|
29
|
-
value: str = Field(max_length=2048)
|
|
30
|
-
"""Value of the tag to update limited ot 2048 characters"""
|
|
31
|
-
|
|
32
|
-
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class SemanticTag(BaseModel):
|
|
36
|
-
key: str
|
|
37
|
-
"""Key of the tag"""
|
|
38
|
-
value: str
|
|
39
|
-
"""Value of the tag"""
|
|
15
|
+
from geovisio.utils.annotations import Annotation, get_picture_annotations
|
|
16
|
+
from geovisio.utils.pic_shape import Geometry
|
|
17
|
+
from geovisio.utils.tags import SemanticTag, SemanticTagUpdate, TagAction
|
|
40
18
|
|
|
41
19
|
|
|
42
20
|
class EntityType(Enum):
|
|
@@ -44,9 +22,7 @@ class EntityType(Enum):
|
|
|
44
22
|
pic = "picture_id"
|
|
45
23
|
seq = "sequence_id"
|
|
46
24
|
annotation = "annotation_id"
|
|
47
|
-
|
|
48
|
-
def entitiy_id_field(self) -> Identifier:
|
|
49
|
-
return Identifier(self.value)
|
|
25
|
+
upload_set = "upload_set_id"
|
|
50
26
|
|
|
51
27
|
|
|
52
28
|
@dataclass
|
|
@@ -62,59 +38,222 @@ class Entity:
|
|
|
62
38
|
return Identifier("sequences_semantics")
|
|
63
39
|
case EntityType.annotation:
|
|
64
40
|
return Identifier("annotations_semantics")
|
|
41
|
+
case EntityType.upload_set:
|
|
42
|
+
return Identifier("upload_sets_semantics")
|
|
65
43
|
case _:
|
|
66
44
|
raise ValueError(f"Unknown entity type: {self.type}")
|
|
67
45
|
|
|
68
|
-
def get_history_table(self) -> Identifier:
|
|
46
|
+
def get_history_table(self) -> Optional[Identifier]:
|
|
69
47
|
match self.type:
|
|
70
48
|
case EntityType.pic:
|
|
71
49
|
return Identifier("pictures_semantics_history")
|
|
72
50
|
case EntityType.seq:
|
|
73
51
|
return Identifier("sequences_semantics_history")
|
|
74
52
|
case EntityType.annotation:
|
|
75
|
-
return Identifier("
|
|
53
|
+
return Identifier("pictures_semantics_history")
|
|
54
|
+
case EntityType.upload_set:
|
|
55
|
+
return None
|
|
76
56
|
case _:
|
|
77
57
|
raise ValueError(f"Unknown entity type: {self.type}")
|
|
78
58
|
|
|
79
59
|
|
|
80
|
-
def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID)
|
|
60
|
+
def update_tags(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation=None):
|
|
81
61
|
"""Update tags for an entity
|
|
82
62
|
Note: this should be done inside an autocommit transaction
|
|
83
63
|
"""
|
|
84
64
|
table_name = entity.get_table()
|
|
85
|
-
fields = [entity.type.entitiy_id_field(), Identifier("key"), Identifier("value")]
|
|
86
65
|
tag_to_add = [t for t in actions if t.action == TagAction.add]
|
|
87
66
|
tag_to_delete = [t for t in actions if t.action == TagAction.delete]
|
|
88
67
|
try:
|
|
89
68
|
if tag_to_delete:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
69
|
+
filter_query = []
|
|
70
|
+
params = [entity.id]
|
|
71
|
+
for tag in tag_to_delete:
|
|
72
|
+
filter_query.append(SQL("(key = %s AND value = %s)"))
|
|
73
|
+
params.append(tag.key)
|
|
74
|
+
params.append(tag.value)
|
|
75
|
+
|
|
94
76
|
cursor.execute(
|
|
95
77
|
SQL(
|
|
96
78
|
"""DELETE FROM {table}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
).format(table=table_name, entity_id=entity.type.entitiy_id_field()),
|
|
102
|
-
{"entity": entity.id, "key_values": [(t.key, t.value) for t in tag_to_delete]},
|
|
79
|
+
WHERE {entity_id} = %s
|
|
80
|
+
AND ({filter})"""
|
|
81
|
+
).format(table=table_name, entity_id=Identifier(entity.type.value), filter=SQL(" OR ").join(filter_query)),
|
|
82
|
+
params,
|
|
103
83
|
)
|
|
104
84
|
if tag_to_add:
|
|
105
|
-
|
|
85
|
+
fields = [Identifier(entity.type.value), Identifier("key"), Identifier("value")]
|
|
86
|
+
if entity.type == EntityType.upload_set:
|
|
87
|
+
# upload_set semantics have no history, the account is directly stored in the table
|
|
88
|
+
fields.append(Identifier("account_id"))
|
|
89
|
+
|
|
90
|
+
with cursor.copy(
|
|
91
|
+
SQL("COPY {table} ({fields}) FROM STDIN").format(
|
|
92
|
+
table=table_name,
|
|
93
|
+
fields=SQL(",").join(fields),
|
|
94
|
+
)
|
|
95
|
+
) as copy:
|
|
106
96
|
for tag in tag_to_add:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
97
|
+
row = [entity.id, tag.key, tag.value]
|
|
98
|
+
if entity.type == EntityType.upload_set:
|
|
99
|
+
row.append(account)
|
|
100
|
+
copy.write_row(row)
|
|
101
|
+
if tag_to_delete and entity.type == EntityType.annotation and not tag_to_add:
|
|
102
|
+
# if tags have been deleted, we check if some annotations are now empty and need to be deleted
|
|
110
103
|
cursor.execute(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
"""DELETE FROM annotations
|
|
105
|
+
WHERE id = %(annotation_id)s AND
|
|
106
|
+
(
|
|
107
|
+
SELECT count(*) AS nb_semantics
|
|
108
|
+
FROM annotations_semantics
|
|
109
|
+
WHERE annotation_id = %(annotation_id)s
|
|
110
|
+
) = 0""",
|
|
111
|
+
{"annotation_id": entity.id},
|
|
115
112
|
)
|
|
113
|
+
if tag_to_add or tag_to_delete:
|
|
114
|
+
# we track the history changes of the semantic tags
|
|
115
|
+
track_semantic_history(cursor, entity, actions, account, annotation)
|
|
116
116
|
except UniqueViolation as e:
|
|
117
117
|
# if the tag already exists, we don't want to add it again
|
|
118
118
|
raise errors.InvalidAPIUsage(
|
|
119
119
|
"Impossible to add semantic tags because of duplicates", payload={"details": {"duplicate": e.diag.message_detail}}
|
|
120
120
|
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SemanticTagUpdateOnAnnotation(SemanticTagUpdate):
|
|
124
|
+
annotation_shape: Geometry
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def track_semantic_history(cursor: Cursor, entity: Entity, actions: List[SemanticTagUpdate], account: UUID, annotation):
|
|
128
|
+
history_table = entity.get_history_table()
|
|
129
|
+
if history_table is None:
|
|
130
|
+
# no history for upload_set semantics
|
|
131
|
+
return
|
|
132
|
+
params = {
|
|
133
|
+
"account_id": account,
|
|
134
|
+
}
|
|
135
|
+
if annotation is not None:
|
|
136
|
+
# the annotations are historized in the pictures_semantics_history table
|
|
137
|
+
# and additional information about the annotation.
|
|
138
|
+
# This makes it easier to track annotations deletions
|
|
139
|
+
params["picture_id"] = annotation.picture_id
|
|
140
|
+
|
|
141
|
+
params["updates"] = Jsonb(
|
|
142
|
+
[
|
|
143
|
+
SemanticTagUpdateOnAnnotation(action=t.action, key=t.key, value=t.value, annotation_shape=annotation.shape).model_dump()
|
|
144
|
+
for t in actions
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
params[entity.type.value] = entity.id
|
|
149
|
+
params["updates"] = Jsonb([t.model_dump() for t in actions])
|
|
150
|
+
|
|
151
|
+
sql = SQL("INSERT INTO {history_table} ({fields}) VALUES ({values})").format(
|
|
152
|
+
history_table=entity.get_history_table(),
|
|
153
|
+
fields=SQL(", ").join([Identifier(k) for k in params.keys()]),
|
|
154
|
+
values=SQL(", ").join([Placeholder(k) for k in params.keys()]),
|
|
155
|
+
)
|
|
156
|
+
cursor.execute(sql, params)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def delete_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str, account: UUID) -> List[Dict]:
|
|
160
|
+
"""Delete all tags from a blurring service on a given picture"""
|
|
161
|
+
annotations_tags = list(get_annotation_tags_from_service(conn, picture_id, service_name))
|
|
162
|
+
|
|
163
|
+
with conn.transaction(), conn.cursor(row_factory=dict_row) as cursor:
|
|
164
|
+
for a in annotations_tags:
|
|
165
|
+
actions = [SemanticTagUpdate(action=TagAction.delete, key=t.key, value=t.value) for t in a.semantics]
|
|
166
|
+
entity = Entity(id=a.id, type=EntityType.annotation)
|
|
167
|
+
update_tags(cursor, entity, actions, account, annotation=a)
|
|
168
|
+
|
|
169
|
+
return annotations_tags
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
QUALIFIER_REGEXP = re.compile(r"^(?P<qualifier>[^\[]*)\[(?P<key>[^=]+)(=(?P<value>.*))?\]$")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class QualifierSemantic:
|
|
177
|
+
qualifier: str
|
|
178
|
+
associated_key: str
|
|
179
|
+
associated_value: Optional[str]
|
|
180
|
+
raw_tag: SemanticTag
|
|
181
|
+
|
|
182
|
+
def qualifies(self, semantic_tag: SemanticTag) -> bool:
|
|
183
|
+
"""Check if a semantic tag is qualified by the qualifier"""
|
|
184
|
+
if semantic_tag.key != self.associated_key:
|
|
185
|
+
return False
|
|
186
|
+
if self.associated_value is None or self.associated_value == "*":
|
|
187
|
+
return True
|
|
188
|
+
return semantic_tag.value == self.associated_value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def as_qualifier(s: SemanticTag) -> Optional[QualifierSemantic]:
|
|
192
|
+
"""Try to convert a semantic tag into a qualifier"""
|
|
193
|
+
m = QUALIFIER_REGEXP.search(s.key)
|
|
194
|
+
if m:
|
|
195
|
+
return QualifierSemantic(
|
|
196
|
+
qualifier=m.group("qualifier"),
|
|
197
|
+
associated_key=m.group("key"),
|
|
198
|
+
associated_value=m.group("value"),
|
|
199
|
+
raw_tag=s,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_qualifiers(semantics: List[SemanticTag]) -> List[QualifierSemantic]:
|
|
204
|
+
"""Find all qualifiers in a list of semantic tags"""
|
|
205
|
+
res = []
|
|
206
|
+
for s in semantics:
|
|
207
|
+
q = as_qualifier(s)
|
|
208
|
+
if q is not None:
|
|
209
|
+
res.append(q)
|
|
210
|
+
return res
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_detection_model_tags(qualifiers: List[QualifierSemantic], service_name: str) -> List[QualifierSemantic]:
|
|
214
|
+
"""Find all detection models associated to a picture, from a given service"""
|
|
215
|
+
res = []
|
|
216
|
+
for q in qualifiers:
|
|
217
|
+
if not q.raw_tag.value.startswith(f"{service_name}-"):
|
|
218
|
+
continue
|
|
219
|
+
if q.qualifier != "detection_model":
|
|
220
|
+
continue
|
|
221
|
+
res.append(q)
|
|
222
|
+
return res
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def find_semantics_from_service(annotation, service_name: str) -> Generator[SemanticTag, None, None]:
|
|
226
|
+
"""Find all semantics tags related from a given bluring service
|
|
227
|
+
|
|
228
|
+
The blurring service will add a `detection_model` qualifier with a value starting by its name (like `SGBlur-yolo11n/0.1.0` for `SGBlur`)
|
|
229
|
+
|
|
230
|
+
This method will return all linked semantics tags, and all their qualifiers.
|
|
231
|
+
"""
|
|
232
|
+
qualifiers = get_qualifiers(annotation.semantics)
|
|
233
|
+
detection_model_tags = find_detection_model_tags(qualifiers, service_name)
|
|
234
|
+
qualified_tags = []
|
|
235
|
+
for s in annotation.semantics:
|
|
236
|
+
for qualifier_tag in detection_model_tags:
|
|
237
|
+
if qualifier_tag.qualifies(s):
|
|
238
|
+
qualified_tags.append(s)
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
# we then have to get all qualifiers on those tags
|
|
242
|
+
related_qualifiers = []
|
|
243
|
+
for q in qualifiers:
|
|
244
|
+
for t in qualified_tags:
|
|
245
|
+
if q.qualifies(t):
|
|
246
|
+
related_qualifiers.append(q.raw_tag)
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
return qualified_tags + related_qualifiers
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_annotation_tags_from_service(conn: Connection, picture_id: UUID, service_name: str) -> Generator[Annotation, None, None]:
|
|
253
|
+
"""Get all annotations semantics from a blurring service"""
|
|
254
|
+
|
|
255
|
+
annotations = get_picture_annotations(conn, picture_id)
|
|
256
|
+
|
|
257
|
+
for a in annotations:
|
|
258
|
+
semantics = [s for s in find_semantics_from_service(a, service_name)]
|
|
259
|
+
yield Annotation(id=a.id, picture_id=a.picture_id, shape=a.shape, semantics=semantics)
|
geovisio/utils/sentry.py
CHANGED
|
@@ -60,7 +60,6 @@ def _wrap_cursor_execute(f):
|
|
|
60
60
|
return f(self, query, params, prepare=prepare, binary=binary)
|
|
61
61
|
|
|
62
62
|
with record_sql_queries(
|
|
63
|
-
hub=hub,
|
|
64
63
|
cursor=self,
|
|
65
64
|
query=query,
|
|
66
65
|
params_list=params,
|
|
@@ -74,7 +73,7 @@ def _wrap_cursor_execute(f):
|
|
|
74
73
|
|
|
75
74
|
|
|
76
75
|
class FileSystemIntegration(Integration):
|
|
77
|
-
"""Add metrics to the 2 most
|
|
76
|
+
"""Add metrics to the 2 most useful filesystem, the 'os file' filesystem and the s3 filesystem"""
|
|
78
77
|
|
|
79
78
|
identifier = "filesystem"
|
|
80
79
|
|