geopic-tag-reader 1.7.0__tar.gz → 1.8.0__tar.gz

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.
Files changed (67) hide show
  1. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/CHANGELOG.md +9 -1
  2. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/PKG-INFO +2 -1
  3. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/__init__.py +1 -1
  4. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/sequence.py +87 -33
  5. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  6. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/en/LC_MESSAGES/geopic_tag_reader.po +2 -2
  7. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/geopic_tag_reader.pot +1 -1
  8. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/pyproject.toml +1 -0
  9. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/.gitignore +0 -0
  10. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/.gitlab-ci.yml +0 -0
  11. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/.pre-commit-config.yaml +0 -0
  12. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/CODE_OF_CONDUCT.md +0 -0
  13. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/LICENSE +0 -0
  14. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/Makefile +0 -0
  15. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/README.md +0 -0
  16. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/develop.md +0 -0
  17. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/images/quality_score.png +0 -0
  18. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/images/quality_score_viewer.png +0 -0
  19. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/index.md +0 -0
  20. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/install.md +0 -0
  21. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/quality_score.md +0 -0
  22. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/tech/api_reference.md +0 -0
  23. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/docs/tech/cli.md +0 -0
  24. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/camera.py +0 -0
  25. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/cameras.csv +0 -0
  26. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/i18n.py +0 -0
  27. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/main.py +0 -0
  28. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/model.py +0 -0
  29. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/py.typed +0 -0
  30. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/reader.py +0 -0
  31. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ar/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  32. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ar/LC_MESSAGES/geopic_tag_reader.po +0 -0
  33. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/br/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  34. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/br/LC_MESSAGES/geopic_tag_reader.po +0 -0
  35. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/da/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  36. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/da/LC_MESSAGES/geopic_tag_reader.po +0 -0
  37. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  38. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/de/LC_MESSAGES/geopic_tag_reader.po +0 -0
  39. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/eo/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  40. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/eo/LC_MESSAGES/geopic_tag_reader.po +0 -0
  41. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  42. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/es/LC_MESSAGES/geopic_tag_reader.po +0 -0
  43. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  44. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/fi/LC_MESSAGES/geopic_tag_reader.po +0 -0
  45. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  46. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/fr/LC_MESSAGES/geopic_tag_reader.po +0 -0
  47. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/hu/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  48. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/hu/LC_MESSAGES/geopic_tag_reader.po +0 -0
  49. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/it/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  50. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/it/LC_MESSAGES/geopic_tag_reader.po +0 -0
  51. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ja/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  52. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ja/LC_MESSAGES/geopic_tag_reader.po +0 -0
  53. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ko/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  54. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ko/LC_MESSAGES/geopic_tag_reader.po +0 -0
  55. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/nl/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  56. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/nl/LC_MESSAGES/geopic_tag_reader.po +0 -0
  57. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/pl/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  58. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/pl/LC_MESSAGES/geopic_tag_reader.po +0 -0
  59. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/sv/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  60. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/sv/LC_MESSAGES/geopic_tag_reader.po +0 -0
  61. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ti/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  62. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/ti/LC_MESSAGES/geopic_tag_reader.po +0 -0
  63. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/zh_Hant/LC_MESSAGES/geopic_tag_reader.mo +0 -0
  64. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/translations/zh_Hant/LC_MESSAGES/geopic_tag_reader.po +0 -0
  65. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/geopic_tag_reader/writer.py +0 -0
  66. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/mkdocs.yml +0 -0
  67. {geopic_tag_reader-1.7.0 → geopic_tag_reader-1.8.0}/pytest.ini +0 -0
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.8.0] - 2025-05-25
11
+
12
+ ### Changed
13
+
14
+ - Improved duplicates detection, now the whole sequence is deduplicated, not only the consecutive pictures
15
+ - **Breaking Change** Now the `duplicate_pictures` in the report is a list of `Duplicate` (instead of `Picture`), so we can get more information about the duplicate.
16
+
10
17
  ## [1.7.0] - 2025-05-25
11
18
 
12
19
  ### Changed
@@ -291,7 +298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
291
298
 
292
299
  - EXIF tag reading methods extracted from [Panoramax/GeoVisio API](https://gitlab.com/panoramax/server/api)
293
300
 
294
- [Unreleased]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.7.0...main
301
+ [Unreleased]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.8.0...main
302
+ [1.8.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.7.0...1.8.0
295
303
  [1.7.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.6.0...1.7.0
296
304
  [1.6.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.5.0...1.6.0
297
305
  [1.5.0]: https://gitlab.com/panoramax/server/geo-picture-tag-reader/-/compare/1.4.2...1.5.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geopic-tag-reader
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: GeoPicTagReader
5
5
  Author-email: Adrien PAVIE <panieravide@riseup.net>
6
6
  Requires-Python: >=3.9
@@ -14,6 +14,7 @@ Requires-Dist: timezonefinder == 6.5.9
14
14
  Requires-Dist: pytz ~= 2025.2
15
15
  Requires-Dist: types-pytz ~= 2025.2.0
16
16
  Requires-Dist: types-python-dateutil ~= 2.9.0
17
+ Requires-Dist: rtree ~= 1.4.0
17
18
  Requires-Dist: flit ~= 3.8.0 ; extra == "build"
18
19
  Requires-Dist: black ~= 25.1.0 ; extra == "dev"
19
20
  Requires-Dist: mypy ~= 1.15.0 ; extra == "dev"
@@ -2,4 +2,4 @@
2
2
  GeoPicTagReader
3
3
  """
4
4
 
5
- __version__ = "1.7.0"
5
+ __version__ = "1.8.0"
@@ -1,9 +1,10 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
2
  from enum import Enum
3
3
  from typing import Optional, List, Tuple
4
4
  from pathlib import PurePath
5
5
  from geopic_tag_reader.reader import GeoPicTags
6
6
  import datetime
7
+ from rtree import index
7
8
  import math
8
9
 
9
10
 
@@ -37,6 +38,7 @@ class SplitParams:
37
38
  class Picture:
38
39
  filename: str
39
40
  metadata: GeoPicTags
41
+ heading_computed: bool = False
40
42
 
41
43
  def distance_to(self, other) -> float:
42
44
  """Computes distance in meters based on Haversine formula"""
@@ -50,7 +52,7 @@ class Picture:
50
52
  distance = R * c
51
53
  return distance
52
54
 
53
- def rotation_angle(self, other) -> Optional[float]:
55
+ def rotation_angle(self, other) -> Optional[int]:
54
56
  return rotation_angle(self.metadata.heading, other.metadata.heading)
55
57
 
56
58
 
@@ -116,11 +118,19 @@ class Sequence:
116
118
  return (otherSeq.from_ts() - self.to_ts(), otherSeq.pictures[0].distance_to(self.pictures[-1])) # type: ignore
117
119
 
118
120
 
121
+ @dataclass
122
+ class Duplicate:
123
+ picture: Picture
124
+ duplicate_of: Picture
125
+ distance: float
126
+ angle: Optional[int]
127
+
128
+
119
129
  @dataclass
120
130
  class DispatchReport:
121
131
  sequences: List[Sequence]
122
- duplicate_pictures: Optional[List[Picture]] = None
123
- sequences_splits: Optional[List[Split]] = None
132
+ duplicate_pictures: List[Duplicate] = field(default_factory=list)
133
+ sequences_splits: List[Split] = field(default_factory=list)
124
134
 
125
135
 
126
136
  def sort_pictures(pictures: List[Picture], method: Optional[SortMethod] = SortMethod.time_asc) -> List[Picture]:
@@ -202,7 +212,38 @@ def sort_pictures(pictures: List[Picture], method: Optional[SortMethod] = SortMe
202
212
  return pictures
203
213
 
204
214
 
205
- def find_duplicates(pictures: List[Picture], params: Optional[MergeParams] = None) -> Tuple[List[Picture], List[Picture]]:
215
+ def are_duplicates(a: Picture, b: Picture, params: MergeParams) -> Optional[Tuple[float, Optional[int]]]:
216
+ """
217
+ Check if 2 pictures are too similar and should be considered duplicates
218
+
219
+ They are duplicates if they are close to each other, and for non 360 pictures, if they are roughly in the same direction.
220
+
221
+ Note that we only consider the direction (also called heading) if it is provided by the camera (and not computed with the sequences geometries)
222
+ since GPS can drift a bit resulting in erratic direction when waiting at a traffic light cf https://gitlab.com/panoramax/server/api/-/issues/231#note_2329723526
223
+
224
+ Return None if not duplicates, or the distance/angle if they are
225
+ """
226
+ dist = a.distance_to(b)
227
+
228
+ if params.maxDistance is None or dist > params.maxDistance:
229
+ return None
230
+
231
+ # Compare angle (if available on both images)
232
+ angle = a.rotation_angle(b)
233
+ # if one of the heading has been computed, we cannot rely on this angle being correct, so we don't consider it for the deduplication
234
+ # it's especially important when stopped and the GPS drift a bit, cf https://gitlab.com/panoramax/server/api/-/issues/231#note_2329723526
235
+ angle_computed = b.heading_computed or a.heading_computed
236
+ if angle is None or angle_computed or params.maxRotationAngle is None:
237
+ return (dist, None)
238
+ if angle <= params.maxRotationAngle:
239
+ return (dist, angle)
240
+ return None
241
+
242
+
243
+ APPROX_DEGREE_TO_METER = 0.00001 # this is roughly 1m
244
+
245
+
246
+ def find_duplicates(pictures: List[Picture], params: Optional[MergeParams] = None) -> Tuple[List[Picture], List[Duplicate]]:
206
247
  """
207
248
  Finds too similar pictures.
208
249
  Note that input list should be properly sorted.
@@ -217,42 +258,51 @@ def find_duplicates(pictures: List[Picture], params: Optional[MergeParams] = Non
217
258
  (Non-duplicates pictures, Duplicates pictures)
218
259
  """
219
260
 
220
- if params is None or not params.is_merge_needed():
261
+ if params is None or not params.is_merge_needed() or not pictures:
221
262
  return (pictures, [])
263
+ assert params.maxDistance
222
264
 
223
265
  nonDups: List[Picture] = []
224
- dups: List[Picture] = []
225
- lastNonDuplicatedPicId = 0
266
+ duplicates = []
267
+ duplicates_idx = set()
226
268
 
227
- for i, currentPic in enumerate(pictures):
228
- if i == 0:
229
- nonDups.append(currentPic)
230
- continue
269
+ rtree_index = index.Index(((i, (p.metadata.lon, p.metadata.lat, p.metadata.lon, p.metadata.lat), None) for i, p in enumerate(pictures)))
231
270
 
232
- prevPic = pictures[lastNonDuplicatedPicId]
271
+ # the rtree will give us all the neighbors in an approximated bounding box,
272
+ # and will check, for all those pictures if some pictures are really closed, using a real haversine distance
273
+ # we do a rough conversion between the maxDistance (in m) to degree, since it's only for the initial bounding box
274
+ # and we use a bbox bigger than necessary (could be half by direction) to not miss duplicates due to the degree to meter approximation
233
275
 
234
- if prevPic.metadata is None or currentPic.metadata is None:
235
- nonDups.append(currentPic)
276
+ bounding_box_tolerance_approx = params.maxDistance * APPROX_DEGREE_TO_METER
277
+ for i, currentPic in enumerate(pictures):
278
+ if i in duplicates_idx:
279
+ # the picture has already been flagged as duplicate by one of its neighbor, we can skip it
236
280
  continue
237
281
 
238
- is_duplicate = False
282
+ bounding_box = (
283
+ currentPic.metadata.lon - bounding_box_tolerance_approx,
284
+ currentPic.metadata.lat - bounding_box_tolerance_approx,
285
+ currentPic.metadata.lon + bounding_box_tolerance_approx,
286
+ currentPic.metadata.lat + bounding_box_tolerance_approx,
287
+ )
239
288
 
240
- # Compare distance
241
- dist = prevPic.distance_to(currentPic)
289
+ near_pics_idx = rtree_index.nearest(bounding_box, num_results=100, objects=False)
242
290
 
243
- if params.maxDistance is not None and dist <= params.maxDistance:
244
- # Compare angle (if available on both images)
245
- angle = prevPic.rotation_angle(currentPic)
246
- if angle is None or params.maxRotationAngle is None or angle <= params.maxRotationAngle:
247
- is_duplicate = True
291
+ for neighbor_idx in near_pics_idx:
292
+ if neighbor_idx == i:
293
+ continue
294
+ if neighbor_idx in duplicates_idx:
295
+ continue
296
+ neighbor = pictures[neighbor_idx]
297
+ duplicate_details = are_duplicates(currentPic, neighbor, params)
298
+ if duplicate_details:
299
+ distance, angle = duplicate_details
300
+ duplicates_idx.add(neighbor_idx)
301
+ duplicates.append(Duplicate(picture=neighbor, duplicate_of=currentPic, distance=round(distance, 2), angle=angle))
248
302
 
249
- if is_duplicate:
250
- dups.append(currentPic)
251
- else:
252
- lastNonDuplicatedPicId = i
253
- nonDups.append(currentPic)
303
+ nonDups.append(currentPic)
254
304
 
255
- return (nonDups, dups)
305
+ return (nonDups, duplicates)
256
306
 
257
307
 
258
308
  def split_in_sequences(pictures: List[Picture], splitParams: Optional[SplitParams] = SplitParams()) -> Tuple[List[Sequence], List[Split]]:
@@ -354,10 +404,14 @@ def dispatch_pictures(
354
404
  # Sort
355
405
  myPics = sort_pictures(pictures, sortMethod)
356
406
 
357
- # De-duplicate
358
- (myPics, dupsPics) = find_duplicates(myPics, mergeParams)
359
-
360
407
  # Split in sequences
361
408
  (mySeqs, splits) = split_in_sequences(myPics, splitParams)
362
409
 
363
- return DispatchReport(mySeqs, dupsPics if len(dupsPics) > 0 else None, splits if len(splits) > 0 else None)
410
+ # De-duplicate inside each sequences
411
+ dups_pics = []
412
+ for s in mySeqs:
413
+ (myPics, dups) = find_duplicates(s.pictures, mergeParams)
414
+ s.pictures = myPics
415
+ dups_pics.extend(dups)
416
+
417
+ return DispatchReport(sequences=mySeqs, duplicate_pictures=dups_pics, sequences_splits=splits)
@@ -7,8 +7,8 @@ msgid ""
7
7
  msgstr ""
8
8
  "Project-Id-Version: PACKAGE VERSION\n"
9
9
  "Report-Msgid-Bugs-To: \n"
10
- "POT-Creation-Date: 2025-08-25 15:13+0200\n"
11
- "PO-Revision-Date: 2025-08-25 15:13+0200\n"
10
+ "POT-Creation-Date: 2025-08-25 15:35+0200\n"
11
+ "PO-Revision-Date: 2025-08-25 15:35+0200\n"
12
12
  "Last-Translator: Automatically generated\n"
13
13
  "Language-Team: none\n"
14
14
  "Language: en\n"
@@ -8,7 +8,7 @@ msgid ""
8
8
  msgstr ""
9
9
  "Project-Id-Version: PACKAGE VERSION\n"
10
10
  "Report-Msgid-Bugs-To: \n"
11
- "POT-Creation-Date: 2025-08-25 15:13+0200\n"
11
+ "POT-Creation-Date: 2025-08-25 15:35+0200\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "pytz ~= 2025.2",
16
16
  "types-pytz ~= 2025.2.0",
17
17
  "types-python-dateutil ~= 2.9.0",
18
+ "rtree ~= 1.4.0",
18
19
  ]
19
20
  requires-python = ">=3.9"
20
21