lightly-studio 0.3.1__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.
Potentially problematic release.
This version of lightly-studio might be problematic. Click here for more details.
- lightly_studio/__init__.py +11 -0
- lightly_studio/api/__init__.py +0 -0
- lightly_studio/api/app.py +110 -0
- lightly_studio/api/cache.py +77 -0
- lightly_studio/api/db.py +133 -0
- lightly_studio/api/db_tables.py +32 -0
- lightly_studio/api/features.py +7 -0
- lightly_studio/api/routes/api/annotation.py +233 -0
- lightly_studio/api/routes/api/annotation_label.py +90 -0
- lightly_studio/api/routes/api/annotation_task.py +38 -0
- lightly_studio/api/routes/api/classifier.py +387 -0
- lightly_studio/api/routes/api/dataset.py +182 -0
- lightly_studio/api/routes/api/dataset_tag.py +257 -0
- lightly_studio/api/routes/api/exceptions.py +96 -0
- lightly_studio/api/routes/api/features.py +17 -0
- lightly_studio/api/routes/api/metadata.py +37 -0
- lightly_studio/api/routes/api/metrics.py +80 -0
- lightly_studio/api/routes/api/sample.py +196 -0
- lightly_studio/api/routes/api/settings.py +45 -0
- lightly_studio/api/routes/api/status.py +19 -0
- lightly_studio/api/routes/api/text_embedding.py +48 -0
- lightly_studio/api/routes/api/validators.py +17 -0
- lightly_studio/api/routes/healthz.py +13 -0
- lightly_studio/api/routes/images.py +104 -0
- lightly_studio/api/routes/webapp.py +51 -0
- lightly_studio/api/server.py +82 -0
- lightly_studio/core/__init__.py +0 -0
- lightly_studio/core/dataset.py +523 -0
- lightly_studio/core/sample.py +77 -0
- lightly_studio/core/start_gui.py +15 -0
- lightly_studio/dataset/__init__.py +0 -0
- lightly_studio/dataset/edge_embedding_generator.py +144 -0
- lightly_studio/dataset/embedding_generator.py +91 -0
- lightly_studio/dataset/embedding_manager.py +163 -0
- lightly_studio/dataset/env.py +16 -0
- lightly_studio/dataset/file_utils.py +35 -0
- lightly_studio/dataset/loader.py +622 -0
- lightly_studio/dataset/mobileclip_embedding_generator.py +144 -0
- lightly_studio/dist_lightly_studio_view_app/_app/env.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/0.DenzbfeK.css +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/LightlyLogo.BNjCIww-.png +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans- +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans-Bold.DGvYQtcs.ttf +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans-Italic-VariableFont_wdth_wght.B4AZ-wl6.ttf +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans-Regular.DxJTClRG.ttf +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans-SemiBold.D3TTYgdB.ttf +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/OpenSans-VariableFont_wdth_wght.BZBpG5Iz.ttf +0 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/SelectableSvgGroup.OwPEPQZu.css +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/SelectableSvgGroup.b653GmVf.css +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/_layout.T-zjSUd3.css +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/assets/useFeatureFlags.CV-KWLNP.css +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/69_IOA4Y.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/B2FVR0s0.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/B90CZVMX.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/B9zumHo5.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/BJXwVxaE.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/Bsi3UGy5.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/Bu7uvVrG.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/Bx1xMsFy.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/BylOuP6i.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/C8I8rFJQ.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CDnpyLsT.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CWj6FrbW.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CYgJF_JY.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CcaPhhk3.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CvOmgdoc.js +93 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/CxtLVaYz.js +3 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/D5-A_Ffd.js +4 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/D6RI2Zrd.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/D6su9Aln.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/D98V7j6A.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/DIRAtgl0.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/DIeogL5L.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/DOlTMNyt.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/DjUWrjOv.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/DjfY96ND.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/H7C68rOM.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/O-EABkf9.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/XO7A28GO.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/hQVEETDE.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/l7KrR96u.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/nAHhluT7.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/r64xT6ao.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/vC4nQVEB.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/chunks/x9G_hzyY.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/entry/app.CjnvpsmS.js +2 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/entry/start.0o1H7wM9.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/0.XRq_TUwu.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/1.B4rNYwVp.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/10.DfBwOEhN.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/11.CWG1ehzT.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/12.CwF2_8mP.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/2.CS4muRY-.js +6 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/3.CWHpKonm.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/4.OUWOLQeV.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/5.Dm6t9F5W.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/6.Bw5ck4gK.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/7.CF0EDTR6.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/8.Cw30LEcV.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/immutable/nodes/9.CPu3CiBc.js +1 -0
- lightly_studio/dist_lightly_studio_view_app/_app/version.json +1 -0
- lightly_studio/dist_lightly_studio_view_app/apple-touch-icon-precomposed.png +0 -0
- lightly_studio/dist_lightly_studio_view_app/apple-touch-icon.png +0 -0
- lightly_studio/dist_lightly_studio_view_app/favicon.png +0 -0
- lightly_studio/dist_lightly_studio_view_app/index.html +44 -0
- lightly_studio/examples/example.py +23 -0
- lightly_studio/examples/example_metadata.py +338 -0
- lightly_studio/examples/example_selection.py +39 -0
- lightly_studio/examples/example_split_work.py +67 -0
- lightly_studio/examples/example_v2.py +21 -0
- lightly_studio/export_schema.py +18 -0
- lightly_studio/few_shot_classifier/__init__.py +0 -0
- lightly_studio/few_shot_classifier/classifier.py +80 -0
- lightly_studio/few_shot_classifier/classifier_manager.py +663 -0
- lightly_studio/few_shot_classifier/random_forest_classifier.py +489 -0
- lightly_studio/metadata/complex_metadata.py +47 -0
- lightly_studio/metadata/gps_coordinate.py +41 -0
- lightly_studio/metadata/metadata_protocol.py +17 -0
- lightly_studio/metrics/__init__.py +0 -0
- lightly_studio/metrics/detection/__init__.py +0 -0
- lightly_studio/metrics/detection/map.py +268 -0
- lightly_studio/models/__init__.py +1 -0
- lightly_studio/models/annotation/__init__.py +0 -0
- lightly_studio/models/annotation/annotation_base.py +171 -0
- lightly_studio/models/annotation/instance_segmentation.py +56 -0
- lightly_studio/models/annotation/links.py +17 -0
- lightly_studio/models/annotation/object_detection.py +47 -0
- lightly_studio/models/annotation/semantic_segmentation.py +44 -0
- lightly_studio/models/annotation_label.py +47 -0
- lightly_studio/models/annotation_task.py +28 -0
- lightly_studio/models/classifier.py +20 -0
- lightly_studio/models/dataset.py +84 -0
- lightly_studio/models/embedding_model.py +30 -0
- lightly_studio/models/metadata.py +208 -0
- lightly_studio/models/sample.py +180 -0
- lightly_studio/models/sample_embedding.py +37 -0
- lightly_studio/models/settings.py +60 -0
- lightly_studio/models/tag.py +96 -0
- lightly_studio/py.typed +0 -0
- lightly_studio/resolvers/__init__.py +7 -0
- lightly_studio/resolvers/annotation_label_resolver/__init__.py +21 -0
- lightly_studio/resolvers/annotation_label_resolver/create.py +27 -0
- lightly_studio/resolvers/annotation_label_resolver/delete.py +28 -0
- lightly_studio/resolvers/annotation_label_resolver/get_all.py +22 -0
- lightly_studio/resolvers/annotation_label_resolver/get_by_id.py +24 -0
- lightly_studio/resolvers/annotation_label_resolver/get_by_ids.py +25 -0
- lightly_studio/resolvers/annotation_label_resolver/get_by_label_name.py +24 -0
- lightly_studio/resolvers/annotation_label_resolver/names_by_ids.py +25 -0
- lightly_studio/resolvers/annotation_label_resolver/update.py +38 -0
- lightly_studio/resolvers/annotation_resolver/__init__.py +33 -0
- lightly_studio/resolvers/annotation_resolver/count_annotations_by_dataset.py +120 -0
- lightly_studio/resolvers/annotation_resolver/create.py +19 -0
- lightly_studio/resolvers/annotation_resolver/create_many.py +96 -0
- lightly_studio/resolvers/annotation_resolver/delete_annotation.py +45 -0
- lightly_studio/resolvers/annotation_resolver/delete_annotations.py +56 -0
- lightly_studio/resolvers/annotation_resolver/get_all.py +74 -0
- lightly_studio/resolvers/annotation_resolver/get_by_id.py +18 -0
- lightly_studio/resolvers/annotation_resolver/update_annotation_label.py +144 -0
- lightly_studio/resolvers/annotation_resolver/update_bounding_box.py +68 -0
- lightly_studio/resolvers/annotation_task_resolver.py +31 -0
- lightly_studio/resolvers/annotations/__init__.py +1 -0
- lightly_studio/resolvers/annotations/annotations_filter.py +89 -0
- lightly_studio/resolvers/dataset_resolver.py +278 -0
- lightly_studio/resolvers/embedding_model_resolver.py +100 -0
- lightly_studio/resolvers/metadata_resolver/__init__.py +15 -0
- lightly_studio/resolvers/metadata_resolver/metadata_filter.py +163 -0
- lightly_studio/resolvers/metadata_resolver/sample/__init__.py +21 -0
- lightly_studio/resolvers/metadata_resolver/sample/bulk_set_metadata.py +48 -0
- lightly_studio/resolvers/metadata_resolver/sample/get_by_sample_id.py +24 -0
- lightly_studio/resolvers/metadata_resolver/sample/get_metadata_info.py +104 -0
- lightly_studio/resolvers/metadata_resolver/sample/get_value_for_sample.py +27 -0
- lightly_studio/resolvers/metadata_resolver/sample/set_value_for_sample.py +53 -0
- lightly_studio/resolvers/sample_embedding_resolver.py +86 -0
- lightly_studio/resolvers/sample_resolver.py +249 -0
- lightly_studio/resolvers/samples_filter.py +81 -0
- lightly_studio/resolvers/settings_resolver.py +58 -0
- lightly_studio/resolvers/tag_resolver.py +276 -0
- lightly_studio/selection/README.md +6 -0
- lightly_studio/selection/mundig.py +105 -0
- lightly_studio/selection/select.py +96 -0
- lightly_studio/selection/select_via_db.py +93 -0
- lightly_studio/selection/selection_config.py +31 -0
- lightly_studio/services/annotations_service/__init__.py +21 -0
- lightly_studio/services/annotations_service/get_annotation_by_id.py +31 -0
- lightly_studio/services/annotations_service/update_annotation.py +65 -0
- lightly_studio/services/annotations_service/update_annotation_label.py +48 -0
- lightly_studio/services/annotations_service/update_annotations.py +29 -0
- lightly_studio/setup_logging.py +19 -0
- lightly_studio/type_definitions.py +19 -0
- lightly_studio/vendor/ACKNOWLEDGEMENTS +422 -0
- lightly_studio/vendor/LICENSE +31 -0
- lightly_studio/vendor/LICENSE_weights_data +50 -0
- lightly_studio/vendor/README.md +5 -0
- lightly_studio/vendor/__init__.py +1 -0
- lightly_studio/vendor/mobileclip/__init__.py +96 -0
- lightly_studio/vendor/mobileclip/clip.py +77 -0
- lightly_studio/vendor/mobileclip/configs/mobileclip_b.json +18 -0
- lightly_studio/vendor/mobileclip/configs/mobileclip_s0.json +18 -0
- lightly_studio/vendor/mobileclip/configs/mobileclip_s1.json +18 -0
- lightly_studio/vendor/mobileclip/configs/mobileclip_s2.json +18 -0
- lightly_studio/vendor/mobileclip/image_encoder.py +67 -0
- lightly_studio/vendor/mobileclip/logger.py +154 -0
- lightly_studio/vendor/mobileclip/models/__init__.py +10 -0
- lightly_studio/vendor/mobileclip/models/mci.py +933 -0
- lightly_studio/vendor/mobileclip/models/vit.py +433 -0
- lightly_studio/vendor/mobileclip/modules/__init__.py +4 -0
- lightly_studio/vendor/mobileclip/modules/common/__init__.py +4 -0
- lightly_studio/vendor/mobileclip/modules/common/mobileone.py +341 -0
- lightly_studio/vendor/mobileclip/modules/common/transformer.py +451 -0
- lightly_studio/vendor/mobileclip/modules/image/__init__.py +4 -0
- lightly_studio/vendor/mobileclip/modules/image/image_projection.py +113 -0
- lightly_studio/vendor/mobileclip/modules/image/replknet.py +188 -0
- lightly_studio/vendor/mobileclip/modules/text/__init__.py +4 -0
- lightly_studio/vendor/mobileclip/modules/text/repmixer.py +281 -0
- lightly_studio/vendor/mobileclip/modules/text/tokenizer.py +38 -0
- lightly_studio/vendor/mobileclip/text_encoder.py +245 -0
- lightly_studio-0.3.1.dist-info/METADATA +520 -0
- lightly_studio-0.3.1.dist-info/RECORD +219 -0
- lightly_studio-0.3.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""This module defines the data model for the classifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmbeddingClassifier(BaseModel):
|
|
11
|
+
"""Base class for the Classifier model."""
|
|
12
|
+
|
|
13
|
+
"""The name of the classifier."""
|
|
14
|
+
classifier_name: str
|
|
15
|
+
|
|
16
|
+
"""The ID of the classifier."""
|
|
17
|
+
classifier_id: UUID
|
|
18
|
+
|
|
19
|
+
"""List of classes supported by the classifier."""
|
|
20
|
+
class_list: list[str]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""This module contains the Dataset model and related enumerations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import cast
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from sqlalchemy.orm import Session as SQLAlchemySession
|
|
11
|
+
from sqlmodel import Field, Session, SQLModel
|
|
12
|
+
|
|
13
|
+
from lightly_studio.models.sample import SampleTable
|
|
14
|
+
from lightly_studio.resolvers import sample_resolver
|
|
15
|
+
from lightly_studio.resolvers.samples_filter import SampleFilter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DatasetBase(SQLModel):
|
|
19
|
+
"""Base class for the Dataset model."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
directory: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DatasetCreate(DatasetBase):
|
|
26
|
+
"""Dataset class when inserting."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DatasetView(DatasetBase):
|
|
30
|
+
"""Dataset class when retrieving."""
|
|
31
|
+
|
|
32
|
+
dataset_id: UUID
|
|
33
|
+
created_at: datetime
|
|
34
|
+
updated_at: datetime
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DatasetTable(DatasetBase, table=True):
|
|
38
|
+
"""This class defines the Dataset model."""
|
|
39
|
+
|
|
40
|
+
__tablename__ = "datasets"
|
|
41
|
+
dataset_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
42
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
|
|
43
|
+
updated_at: datetime = Field(
|
|
44
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get_samples(
|
|
48
|
+
self,
|
|
49
|
+
offset: int = 0,
|
|
50
|
+
limit: int | None = None,
|
|
51
|
+
filters: SampleFilter | None = None,
|
|
52
|
+
text_embedding: list[float] | None = None,
|
|
53
|
+
sample_ids: list[UUID] | None = None,
|
|
54
|
+
) -> Sequence[SampleTable]:
|
|
55
|
+
"""Retrieve samples for this dataset with optional filtering.
|
|
56
|
+
|
|
57
|
+
Just passes the parameters to the sample resolver.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
offset: Offset for pagination.
|
|
61
|
+
limit: Limit for pagination.
|
|
62
|
+
filters: Optional filters to apply.
|
|
63
|
+
text_embedding: Optional text embedding for filtering.
|
|
64
|
+
sample_ids: Optional list of sample IDs to filter by.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A sequence of SampleTable objects.
|
|
68
|
+
"""
|
|
69
|
+
# Get the session from the instance.
|
|
70
|
+
# SQLAlchemy Session is compatible with SQLModel's Session at runtime,
|
|
71
|
+
# but we have to help mypy.
|
|
72
|
+
session = cast(Session, SQLAlchemySession.object_session(self))
|
|
73
|
+
if session is None:
|
|
74
|
+
raise RuntimeError("No database session found for this instance")
|
|
75
|
+
|
|
76
|
+
return sample_resolver.get_all_by_dataset_id(
|
|
77
|
+
session=session,
|
|
78
|
+
dataset_id=self.dataset_id,
|
|
79
|
+
offset=offset,
|
|
80
|
+
limit=limit,
|
|
81
|
+
filters=filters,
|
|
82
|
+
text_embedding=text_embedding,
|
|
83
|
+
sample_ids=sample_ids,
|
|
84
|
+
).samples
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""This module defines the Embedding_Model model for the application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from sqlmodel import CHAR, Column, Field, SQLModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmbeddingModelBase(SQLModel):
|
|
12
|
+
"""Base class for the EmbeddingModel."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
parameter_count_in_mb: int | None = None
|
|
16
|
+
embedding_model_hash: str = Field(default="", sa_column=Column(CHAR(128)))
|
|
17
|
+
embedding_dimension: int
|
|
18
|
+
dataset_id: UUID = Field(default=None, foreign_key="datasets.dataset_id")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EmbeddingModelCreate(EmbeddingModelBase):
|
|
22
|
+
"""Model used for creating an embedding model."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EmbeddingModelTable(EmbeddingModelBase, table=True):
|
|
26
|
+
"""This class defines the EmbeddingModel model."""
|
|
27
|
+
|
|
28
|
+
__tablename__ = "embedding_models"
|
|
29
|
+
embedding_model_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
30
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Metadata models for storing custom metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from uuid import UUID, uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from sqlalchemy.orm.attributes import flag_modified
|
|
11
|
+
from sqlalchemy.types import JSON
|
|
12
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
13
|
+
|
|
14
|
+
from lightly_studio.metadata.complex_metadata import (
|
|
15
|
+
COMPLEX_METADATA_TYPES,
|
|
16
|
+
deserialize_complex_metadata,
|
|
17
|
+
serialize_complex_metadata,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from lightly_studio.models.sample import SampleTable
|
|
22
|
+
else:
|
|
23
|
+
SampleTable = object
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
TYPE_TO_NAME_MAP = {
|
|
27
|
+
bool: "boolean",
|
|
28
|
+
int: "integer",
|
|
29
|
+
float: "float",
|
|
30
|
+
str: "string",
|
|
31
|
+
list: "list",
|
|
32
|
+
dict: "dict",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
NAME_TO_TYPE_MAP = {
|
|
36
|
+
"string": str,
|
|
37
|
+
"integer": int,
|
|
38
|
+
"float": float,
|
|
39
|
+
"boolean": bool,
|
|
40
|
+
"list": list,
|
|
41
|
+
"dict": dict,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_type_name(value: Any) -> str:
|
|
46
|
+
"""Get the type name for a value.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value: The value to get the type name for.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The type name as a string.
|
|
53
|
+
"""
|
|
54
|
+
if value is None:
|
|
55
|
+
return "null"
|
|
56
|
+
# Check if it's a complex metadata type.
|
|
57
|
+
for name, cls in COMPLEX_METADATA_TYPES.items():
|
|
58
|
+
if isinstance(value, cls):
|
|
59
|
+
return name
|
|
60
|
+
|
|
61
|
+
# Return mapped type name or fallback to class name.
|
|
62
|
+
return TYPE_TO_NAME_MAP.get(type(value), type(value).__name__.lower())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_type_compatibility(expected_type: str, value: Any) -> bool:
|
|
66
|
+
"""Validate that a value is compatible with an expected type.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
expected_type: The expected type name.
|
|
70
|
+
value: The value to validate.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if compatible, False otherwise.
|
|
74
|
+
"""
|
|
75
|
+
if value is None:
|
|
76
|
+
return expected_type == "null"
|
|
77
|
+
|
|
78
|
+
# Check complex types.
|
|
79
|
+
if expected_type in COMPLEX_METADATA_TYPES:
|
|
80
|
+
expected_complex_cls = COMPLEX_METADATA_TYPES[expected_type]
|
|
81
|
+
assert expected_complex_cls is not None
|
|
82
|
+
return isinstance(value, expected_complex_cls)
|
|
83
|
+
|
|
84
|
+
# Check simple types
|
|
85
|
+
expected_cls = NAME_TO_TYPE_MAP.get(expected_type)
|
|
86
|
+
if expected_cls is None:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
return isinstance(value, expected_cls)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MetadataBase(SQLModel):
|
|
93
|
+
"""Base class for CustomMetadata models."""
|
|
94
|
+
|
|
95
|
+
custom_metadata_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
96
|
+
created_at: datetime = Field(
|
|
97
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
98
|
+
)
|
|
99
|
+
updated_at: datetime = Field(
|
|
100
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
101
|
+
)
|
|
102
|
+
# Dictionary storing the actual metadata values as JSON.
|
|
103
|
+
data: dict[str, Any] = Field(
|
|
104
|
+
default_factory=dict,
|
|
105
|
+
sa_type=JSON,
|
|
106
|
+
description="Custom metadata stored as JSON",
|
|
107
|
+
)
|
|
108
|
+
# Dictionary storing the metadata schema.
|
|
109
|
+
metadata_schema: dict[str, str] = Field(
|
|
110
|
+
default_factory=dict,
|
|
111
|
+
sa_type=JSON,
|
|
112
|
+
description="Schema information for metadata keys",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def ensure_schema(self, key: str, value: Any) -> None:
|
|
116
|
+
"""Ensure schema exists for a key and validate value type.
|
|
117
|
+
|
|
118
|
+
This method handles schema management for metadata keys:
|
|
119
|
+
If the key doesn't exist in the schema, it creates a new entry
|
|
120
|
+
with the inferred type from the provided value
|
|
121
|
+
If the key exists, it validates that the new value matches
|
|
122
|
+
the expected type from the schema
|
|
123
|
+
|
|
124
|
+
This ensures type consistency across metadata operations and prevents
|
|
125
|
+
accidental type mismatches that could cause issues in applications.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
key: The metadata key to validate/update schema for
|
|
129
|
+
value: The value to validate/use for type inference
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If the value type doesn't match existing schema
|
|
133
|
+
"""
|
|
134
|
+
if key not in self.metadata_schema:
|
|
135
|
+
# New key - create schema with actual type name.
|
|
136
|
+
self.metadata_schema[key] = get_type_name(value)
|
|
137
|
+
else:
|
|
138
|
+
# Existing key - validate type.
|
|
139
|
+
existing_type = self.metadata_schema[key]
|
|
140
|
+
if not validate_type_compatibility(existing_type, value):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Value type mismatch for key '{key}'. "
|
|
143
|
+
f"Expected {existing_type}, got {get_type_name(value)}."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def set_value(self, key: str, value: Any) -> None:
|
|
147
|
+
"""Set a metadata value with schema validation and database tracking.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
key: The metadata key to set
|
|
151
|
+
value: The value to set
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: If the value type doesn't match the schema
|
|
155
|
+
"""
|
|
156
|
+
self.ensure_schema(key, value)
|
|
157
|
+
# Serialize complex metadata for storage.
|
|
158
|
+
self.data[key] = serialize_complex_metadata(value)
|
|
159
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
160
|
+
# Mark the object as modified so SQLAlchemy knows to update it.
|
|
161
|
+
flag_modified(self, "data")
|
|
162
|
+
flag_modified(self, "metadata_schema")
|
|
163
|
+
|
|
164
|
+
def get_value(self, key: str) -> Any:
|
|
165
|
+
"""Get a metadata value with automatic deserialization.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
key: The metadata key.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The deserialized value (complex metadata object if applicable)
|
|
172
|
+
or None if the key doesn't exist.
|
|
173
|
+
"""
|
|
174
|
+
value = self.data.get(key)
|
|
175
|
+
if value is not None:
|
|
176
|
+
# Get expected type from schema for deserialization.
|
|
177
|
+
expected_type = self.metadata_schema.get(key)
|
|
178
|
+
if expected_type:
|
|
179
|
+
return deserialize_complex_metadata(value, expected_type)
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class MetadataCreate(MetadataBase):
|
|
184
|
+
"""Input class for Metadata model."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SampleMetadataTable(MetadataBase, table=True):
|
|
188
|
+
"""This class defines the SampleMetadataTable model."""
|
|
189
|
+
|
|
190
|
+
__tablename__ = "metadata"
|
|
191
|
+
sample_id: UUID = Field(foreign_key="samples.sample_id")
|
|
192
|
+
|
|
193
|
+
sample: SampleTable = Relationship(back_populates="metadata_dict")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SampleMetadataView(SQLModel):
|
|
197
|
+
"""Sample metadata class when retrieving."""
|
|
198
|
+
|
|
199
|
+
data: dict[str, Any]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class MetadataInfoView(BaseModel):
|
|
203
|
+
"""Metadata info response model for API endpoints."""
|
|
204
|
+
|
|
205
|
+
name: str = Field(description="The metadata key name")
|
|
206
|
+
type: str = Field(description="The metadata type (e.g., 'string', 'integer', 'float')")
|
|
207
|
+
min: int | float | None = Field(None, description="Minimum value for numerical metadata")
|
|
208
|
+
max: int | float | None = Field(None, description="Maximum value for numerical metadata")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""This module defines the User model for the application."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import TYPE_CHECKING, Any, List, Literal, Optional
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import Mapped, Session
|
|
8
|
+
from sqlmodel import Field, Relationship, SQLModel
|
|
9
|
+
|
|
10
|
+
from lightly_studio.models.annotation.annotation_base import AnnotationView
|
|
11
|
+
from lightly_studio.resolvers import metadata_resolver
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from lightly_studio.models.annotation.annotation_base import (
|
|
15
|
+
AnnotationBaseTable,
|
|
16
|
+
)
|
|
17
|
+
from lightly_studio.models.metadata import (
|
|
18
|
+
SampleMetadataTable,
|
|
19
|
+
SampleMetadataView,
|
|
20
|
+
)
|
|
21
|
+
from lightly_studio.models.sample_embedding import SampleEmbeddingTable
|
|
22
|
+
from lightly_studio.models.tag import TagTable
|
|
23
|
+
else:
|
|
24
|
+
AnnotationBaseTable = object
|
|
25
|
+
SampleEmbeddingTable = object
|
|
26
|
+
SampleMetadataTable = object
|
|
27
|
+
TagTable = object
|
|
28
|
+
SampleMetadataView = object
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SampleBase(SQLModel):
|
|
32
|
+
"""Base class for the Sample model."""
|
|
33
|
+
|
|
34
|
+
"""The name of the image file."""
|
|
35
|
+
file_name: str
|
|
36
|
+
|
|
37
|
+
"""The width of the image in pixels."""
|
|
38
|
+
width: int
|
|
39
|
+
|
|
40
|
+
"""The height of the image in pixels."""
|
|
41
|
+
height: int
|
|
42
|
+
|
|
43
|
+
"""The dataset ID to which the sample belongs."""
|
|
44
|
+
dataset_id: UUID = Field(default=None, foreign_key="datasets.dataset_id")
|
|
45
|
+
|
|
46
|
+
"""The dataset image path."""
|
|
47
|
+
file_path_abs: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SampleCreate(SampleBase):
|
|
51
|
+
"""Sample class when inserting."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SampleViewForAnnotation(SQLModel):
|
|
55
|
+
"""Sample class for annotation view."""
|
|
56
|
+
|
|
57
|
+
"""The name of the image file."""
|
|
58
|
+
file_path_abs: str
|
|
59
|
+
sample_id: UUID
|
|
60
|
+
|
|
61
|
+
"""The width of the image in pixels."""
|
|
62
|
+
width: int
|
|
63
|
+
|
|
64
|
+
"""The height of the image in pixels."""
|
|
65
|
+
height: int
|
|
66
|
+
|
|
67
|
+
created_at: datetime
|
|
68
|
+
updated_at: datetime
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SampleTagLinkTable(SQLModel, table=True):
|
|
72
|
+
"""Model to define links between Sample and Tag Many-to-Many."""
|
|
73
|
+
|
|
74
|
+
sample_id: Optional[UUID] = Field(
|
|
75
|
+
default=None, foreign_key="samples.sample_id", primary_key=True
|
|
76
|
+
)
|
|
77
|
+
tag_id: Optional[UUID] = Field(default=None, foreign_key="tags.tag_id", primary_key=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SampleTable(SampleBase, table=True):
|
|
81
|
+
"""This class defines the Sample model."""
|
|
82
|
+
|
|
83
|
+
__tablename__ = "samples"
|
|
84
|
+
sample_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
85
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
|
|
86
|
+
updated_at: datetime = Field(
|
|
87
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
88
|
+
)
|
|
89
|
+
annotations: Mapped[List["AnnotationBaseTable"]] = Relationship(
|
|
90
|
+
back_populates="sample",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
"""The tag ids associated with the sample."""
|
|
94
|
+
tags: Mapped[List["TagTable"]] = Relationship(
|
|
95
|
+
back_populates="samples", link_model=SampleTagLinkTable
|
|
96
|
+
)
|
|
97
|
+
embeddings: Mapped[List["SampleEmbeddingTable"]] = Relationship(back_populates="sample")
|
|
98
|
+
metadata_dict: "SampleMetadataTable" = Relationship(back_populates="sample")
|
|
99
|
+
|
|
100
|
+
def __getitem__(self, key: str) -> Any:
|
|
101
|
+
"""Provides dict-like access to sample metadata.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
key: The metadata key to access.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The metadata value for the given key, or None if the key doesn't
|
|
108
|
+
exist.
|
|
109
|
+
"""
|
|
110
|
+
if self.metadata_dict is None:
|
|
111
|
+
return None
|
|
112
|
+
return self.metadata_dict.get_value(key)
|
|
113
|
+
|
|
114
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
115
|
+
"""Sets a metadata key-value pair for this sample.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
key: The metadata key.
|
|
119
|
+
value: The metadata value.
|
|
120
|
+
|
|
121
|
+
Note:
|
|
122
|
+
If the sample has no metadata, a new Metadata Table instance
|
|
123
|
+
will be created. Changes are automatically committed to the
|
|
124
|
+
database.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
RuntimeError: If no database session is found.
|
|
128
|
+
"""
|
|
129
|
+
# Get the session from the instance
|
|
130
|
+
session = Session.object_session(self)
|
|
131
|
+
if session is None:
|
|
132
|
+
raise RuntimeError("No database session found for this instance")
|
|
133
|
+
|
|
134
|
+
# Use metadata_resolver to handle the database operations.
|
|
135
|
+
# Added type: ignore to avoid type checking issues. SQLAlchemy and
|
|
136
|
+
# SQLModel sessions are compatible at runtime but have different type
|
|
137
|
+
# annotations.
|
|
138
|
+
metadata_resolver.set_value_for_sample(
|
|
139
|
+
session=session, # type: ignore[arg-type]
|
|
140
|
+
sample_id=self.sample_id,
|
|
141
|
+
key=key,
|
|
142
|
+
value=value,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
TagKind = Literal[
|
|
147
|
+
"sample",
|
|
148
|
+
"annotation",
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SampleView(SQLModel):
|
|
153
|
+
"""Sample class when retrieving."""
|
|
154
|
+
|
|
155
|
+
class SampleViewTag(SQLModel):
|
|
156
|
+
"""Tag view inside Sample view."""
|
|
157
|
+
|
|
158
|
+
tag_id: UUID
|
|
159
|
+
name: str
|
|
160
|
+
kind: TagKind
|
|
161
|
+
created_at: datetime
|
|
162
|
+
updated_at: datetime
|
|
163
|
+
|
|
164
|
+
"""The name of the image file."""
|
|
165
|
+
file_name: str
|
|
166
|
+
file_path_abs: str
|
|
167
|
+
sample_id: UUID
|
|
168
|
+
dataset_id: UUID
|
|
169
|
+
annotations: List["AnnotationView"] = Field([])
|
|
170
|
+
tags: List[SampleViewTag] = Field([])
|
|
171
|
+
metadata_dict: Optional["SampleMetadataView"] = None
|
|
172
|
+
width: int
|
|
173
|
+
height: int
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SampleViewsWithCount(SQLModel):
|
|
177
|
+
"""Response model for counted samples."""
|
|
178
|
+
|
|
179
|
+
data: List[SampleView]
|
|
180
|
+
total_count: int
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""This module defines the SampleEmbedding model for the application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import ARRAY, Float
|
|
9
|
+
from sqlmodel import Column, Field, Relationship, SQLModel
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from lightly_studio.models.sample import SampleTable
|
|
13
|
+
|
|
14
|
+
else:
|
|
15
|
+
SampleTable = object
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SampleEmbeddingBase(SQLModel):
|
|
19
|
+
"""Base class for the Embeddings used for Samples."""
|
|
20
|
+
|
|
21
|
+
sample_embedding_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
22
|
+
|
|
23
|
+
sample_id: UUID = Field(foreign_key="samples.sample_id")
|
|
24
|
+
|
|
25
|
+
embedding_model_id: UUID = Field(foreign_key="embedding_models.embedding_model_id")
|
|
26
|
+
embedding: list[float] = Field(sa_column=Column(ARRAY(Float)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SampleEmbeddingCreate(SampleEmbeddingBase):
|
|
30
|
+
"""Sample class when inserting."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SampleEmbeddingTable(SampleEmbeddingBase, table=True):
|
|
34
|
+
"""This class defines the SampleEmbedding model."""
|
|
35
|
+
|
|
36
|
+
__tablename__ = "sample_embeddings"
|
|
37
|
+
sample: SampleTable = Relationship(back_populates="embeddings")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""This module contains settings model for user preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from uuid import UUID, uuid4
|
|
8
|
+
|
|
9
|
+
from sqlmodel import Field, SQLModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GridViewSampleRenderingType(str, Enum):
|
|
13
|
+
"""Defines how samples are rendered in the grid view."""
|
|
14
|
+
|
|
15
|
+
COVER = "cover"
|
|
16
|
+
CONTAIN = "contain"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SettingBase(SQLModel):
|
|
20
|
+
"""Base class for Settings model."""
|
|
21
|
+
|
|
22
|
+
grid_view_sample_rendering: GridViewSampleRenderingType = Field(
|
|
23
|
+
default=GridViewSampleRenderingType.CONTAIN,
|
|
24
|
+
description="Controls how samples are rendered in the grid view",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Keyboard shortcuts.
|
|
28
|
+
key_hide_annotations: str = Field(
|
|
29
|
+
default="v",
|
|
30
|
+
description="Key to temporarily hide annotations while pressed",
|
|
31
|
+
)
|
|
32
|
+
key_go_back: str = Field(
|
|
33
|
+
default="Escape",
|
|
34
|
+
description="Key to navigate back from detail view to grid view",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# New setting for annotation text visibility.
|
|
38
|
+
show_annotation_text_labels: bool = Field(
|
|
39
|
+
default=True,
|
|
40
|
+
description="Controls whether to show text labels on annotations",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SettingView(SettingBase):
|
|
45
|
+
"""View class for Settings model."""
|
|
46
|
+
|
|
47
|
+
setting_id: UUID
|
|
48
|
+
created_at: datetime
|
|
49
|
+
updated_at: datetime
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SettingTable(SettingBase, table=True):
|
|
53
|
+
"""This class defines the Setting model."""
|
|
54
|
+
|
|
55
|
+
__tablename__ = "settings"
|
|
56
|
+
setting_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
57
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
|
|
58
|
+
updated_at: datetime = Field(
|
|
59
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
60
|
+
)
|