Compare commits
47
Commits
9b4f0daf58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5faf3ef2a7 | ||
|
|
007161a927 | ||
|
|
d804cc4a13 | ||
|
|
6075848c91 | ||
|
|
cdde0131a7 | ||
|
|
9a3694fc98 | ||
|
|
cbac5ded35 | ||
|
|
7e9c4474ac | ||
|
|
3c8845aa6d | ||
|
|
604b00c8ed | ||
|
|
26edf8d372 | ||
|
|
b6f53c691b | ||
|
|
4a045125aa | ||
|
|
b01e1213de | ||
|
|
97ac547058 | ||
|
|
b828347ca3 | ||
|
|
51978810f4 | ||
|
|
5008c0970e | ||
|
|
b6b35448b0 | ||
|
|
28abd22571 | ||
|
|
829cfd2e00 | ||
|
|
f0a3bf89b0 | ||
|
|
a124a72c96 | ||
|
|
f858877bb5 | ||
|
|
84c8133252 | ||
|
|
bf098e16c8 | ||
|
|
36523b750d | ||
|
|
3806cd7618 | ||
|
|
9985561130 | ||
|
|
609800483b | ||
|
|
051ce2c771 | ||
|
|
5a04db5c11 | ||
|
|
18541c95de | ||
|
|
8c061ce8d5 | ||
|
|
63ed329fd9 | ||
|
|
4a8e091a2d | ||
|
|
86935ab18e | ||
|
|
fcbae0d87b | ||
|
|
60c0993d59 | ||
|
|
f1bfd02b7e | ||
|
|
c3d60bbd44 | ||
|
|
a8458c11f1 | ||
|
|
3dd8307249 | ||
|
|
f8e435bfe2 | ||
|
|
f96159829c | ||
|
|
25286c3eab | ||
|
|
0ba860b892 |
@@ -0,0 +1,11 @@
|
||||
name: Formatting Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
formatting-check:
|
||||
uses: brian/CI-templates/.gitea/workflows/web/[email protected]
|
||||
@@ -0,0 +1,13 @@
|
||||
name: Python Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
python-code-quality:
|
||||
uses: brian/CI-templates/.gitea/workflows/python/[email protected]
|
||||
with:
|
||||
python-version: ${{ vars.PYTHON_VERSION }}
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Python Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
python-test:
|
||||
uses: brian/CI-templates/.gitea/workflows/python/[email protected]
|
||||
with:
|
||||
python-version: ${{ vars.PYTHON_VERSION }}
|
||||
coverage-package: "data_store"
|
||||
api-url: ${{ vars.API_URL }}
|
||||
secrets:
|
||||
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Sync Label Studio Annotations
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# This job will run every day at 03:00 AM
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
sync_storage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync Label Studio to target storage
|
||||
env:
|
||||
LABEL_STUDIO_API_TOKEN: ${{ secrets.LABEL_STUDIO_API_TOKEN }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $LABEL_STUDIO_API_TOKEN" \
|
||||
"https://label-studio.gt-proj.com/api/storages/export/s3/1/sync"
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ vars.PYTHON_VERSION }}
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Sync target storage to annotations
|
||||
env:
|
||||
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
|
||||
MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }}
|
||||
MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }}
|
||||
MINIO_BUCKET: ${{ vars.MINIO_BUCKET }}
|
||||
run: |
|
||||
uv run python scripts/sync_label_studio_annotations.py
|
||||
@@ -0,0 +1,40 @@
|
||||
repos:
|
||||
# General repository hygiene hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: debug-statements
|
||||
- id: name-tests-test
|
||||
- id: check-merge-conflict
|
||||
|
||||
# Python linting and formatting with Ruff
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.8
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
# Static type checking with mypy
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
||||
# Python syntax modernization with pyupgrade
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.20.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--py311-plus"]
|
||||
|
||||
# Formatting for Markdown, JSON, and YAML with Prettier
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
files: "\\.(md|json|yaml|yml)$"
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
DTO package for visual-semiotic-ai-analysis.
|
||||
Exposes core data transfer objects and enums for use throughout the project.
|
||||
"""
|
||||
|
||||
from .angle_enum import AngleEnum
|
||||
from .annotation import Annotation
|
||||
from .colour_enum import ColourEnum
|
||||
from .contact_enum import ContactEnum
|
||||
from .depth_enum import DepthEnum
|
||||
from .distance_enum import DistanceEnum
|
||||
from .job import Job
|
||||
from .lighting_enum import LightingEnum
|
||||
from .point_of_view_enum import PointOfViewEnum
|
||||
|
||||
__all__ = [
|
||||
"AngleEnum",
|
||||
"Annotation",
|
||||
"ContactEnum",
|
||||
"DistanceEnum",
|
||||
"Job",
|
||||
"PointOfViewEnum",
|
||||
"ColourEnum",
|
||||
"DepthEnum",
|
||||
"LightingEnum",
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Definition of AngleEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AngleEnum(StrEnum):
|
||||
"""Enumeration for Angle values."""
|
||||
|
||||
HIGH = "high"
|
||||
EYE_LEVEL = "eye-level"
|
||||
LOW = "low"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Definition of Annotation DTO."""
|
||||
|
||||
from .type_checking_base_model import TypeCheckingBaseModel
|
||||
from .angle_enum import AngleEnum
|
||||
from .colour_enum import ColourEnum
|
||||
from .contact_enum import ContactEnum
|
||||
from .depth_enum import DepthEnum
|
||||
from .distance_enum import DistanceEnum
|
||||
from .lighting_enum import LightingEnum
|
||||
from .point_of_view_enum import PointOfViewEnum
|
||||
|
||||
|
||||
class Annotation(TypeCheckingBaseModel):
|
||||
"""Data Transfer Object representing an Annotation."""
|
||||
|
||||
angle: AngleEnum | None
|
||||
angle_uncertainty: bool
|
||||
colour: ColourEnum | None
|
||||
colour_uncertainty: bool
|
||||
contact: ContactEnum | None
|
||||
contact_uncertainty: bool
|
||||
depth: DepthEnum | None
|
||||
depth_uncertainty: bool
|
||||
distance: DistanceEnum | None
|
||||
distance_uncertainty: bool
|
||||
lighting: LightingEnum | None
|
||||
lighting_uncertainty: bool
|
||||
point_of_view: PointOfViewEnum | None
|
||||
point_of_view_uncertainty: bool
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Definition of ColourEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ColourEnum(StrEnum):
|
||||
"""Enumeration for Colour values."""
|
||||
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Definition of ContactEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ContactEnum(StrEnum):
|
||||
"""Enumeration for Contact."""
|
||||
|
||||
OFFER = "offer"
|
||||
DEMAND = "demand"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Definition of DepthEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class DepthEnum(StrEnum):
|
||||
"""Enumeration for Depth values."""
|
||||
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Definition of DistanceEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class DistanceEnum(StrEnum):
|
||||
"""Enumeration for Distance values."""
|
||||
|
||||
CLOSE = "close"
|
||||
MEDIUM = "medium"
|
||||
LONG = "long"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Definition of Job DTO."""
|
||||
|
||||
from uuid import uuid4, UUID
|
||||
from pydantic import Field
|
||||
from PIL import Image
|
||||
|
||||
from .type_checking_base_model import TypeCheckingBaseModel
|
||||
from .annotation import Annotation
|
||||
|
||||
|
||||
class Job(TypeCheckingBaseModel):
|
||||
"""Data Transfer Object representing a Job."""
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration for Job DTO."""
|
||||
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
job_id: UUID = Field(default_factory=uuid4)
|
||||
image: Image.Image
|
||||
annotation: Annotation
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Definition of LightingEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class LightingEnum(StrEnum):
|
||||
"""Enumeration for Lighting values."""
|
||||
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Definition of PointOfViewEnum DTO."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class PointOfViewEnum(StrEnum):
|
||||
"""Enumeration for Point of View."""
|
||||
|
||||
FRONTAL = "frontal"
|
||||
OBLIQUE = "oblique"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Definition of class_base class."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TypeCheckingBaseModel(BaseModel):
|
||||
"""BaseModel with added type checking on input types."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True, # ensure that dynamic validations adhere to defined types
|
||||
frozen=True, # ensure data immutability
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Interfaces package for visual-semiotic-ai-analysis.
|
||||
Exposes repository interfaces for use throughout the project.
|
||||
"""
|
||||
|
||||
from .annotation_repository_interface import AnnotationRepositoryInterface
|
||||
from .job_repository_interface import JobRepositoryInterface
|
||||
from .image_repository_interface import ImageRepositoryInterface
|
||||
|
||||
__all__ = [
|
||||
"JobRepositoryInterface",
|
||||
"ImageRepositoryInterface",
|
||||
"AnnotationRepositoryInterface",
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Definition of AnnotationRepositoryInterface."""
|
||||
|
||||
from uuid import UUID
|
||||
from abc import ABC, abstractmethod
|
||||
from data_store.dto import Annotation
|
||||
|
||||
|
||||
class AnnotationRepositoryInterface(ABC):
|
||||
"""AnnotationRepository interface."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
"""Retrieve an Annotation by its associated Job ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
"""Update or create an Annotation."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
"""Delete an Annotation by its associated Job ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all Job IDs with associated Annotations in the repository."""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Definition of ImageRepositoryInterface"""
|
||||
|
||||
from uuid import UUID
|
||||
from abc import ABC, abstractmethod
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ImageRepositoryInterface(ABC):
|
||||
"""ImageRepository interface."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
"""Retrieve an Image by its ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
"""Update or create an Image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
"""Delete an Image by its ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all image IDs in the repository."""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Definition of JobRepositoryInterface."""
|
||||
|
||||
from uuid import UUID
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from data_store.dto import Job
|
||||
|
||||
|
||||
class JobRepositoryInterface(ABC):
|
||||
"""JobRepository interface."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
"""Retrieve a Job by its ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def put(self, job: Job) -> None:
|
||||
"""Update or create a Job."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
"""Delete a Job by its ID."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all Job IDs in the repository."""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Repositories package for visual-semiotic-ai-analysis.
|
||||
Exposes repository implementations for use throughout the project.
|
||||
"""
|
||||
|
||||
from .annotation_repository import AnnotationRepository
|
||||
from .image_repository import ImageRepository
|
||||
from .job_repository import JobRepository
|
||||
|
||||
__all__ = [
|
||||
"AnnotationRepository",
|
||||
"ImageRepository",
|
||||
"JobRepository",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Definition of AnnotationRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
import json
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
|
||||
from python_repositories.adapters import MinioAdapter
|
||||
|
||||
from data_store.interfaces import AnnotationRepositoryInterface
|
||||
from data_store.dto import (
|
||||
Annotation,
|
||||
AngleEnum,
|
||||
ColourEnum,
|
||||
ContactEnum,
|
||||
DepthEnum,
|
||||
DistanceEnum,
|
||||
LightingEnum,
|
||||
PointOfViewEnum,
|
||||
)
|
||||
|
||||
|
||||
class AnnotationRepository(MinioAdapter, AnnotationRepositoryInterface):
|
||||
"""AnnotationRepository implementation using MinIO as the backend."""
|
||||
|
||||
encoding: str = "utf-8"
|
||||
data_format: str = "json"
|
||||
content_type: str = "application/json"
|
||||
folder_name: str = "annotations"
|
||||
|
||||
@classmethod
|
||||
def _object_name(cls, job_id: UUID) -> str:
|
||||
"""Generate the object name for a given job ID."""
|
||||
# Check input
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Return object name
|
||||
return f"{cls.folder_name}/{job_id}.{cls.data_format}"
|
||||
|
||||
def __enter__(self) -> AnnotationRepository:
|
||||
"""Enter the runtime context related to this object."""
|
||||
super().__enter__()
|
||||
return self
|
||||
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
"""Retrieve an Annotation by its job ID."""
|
||||
# Check input
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Get object
|
||||
object_name = self._object_name(job_id)
|
||||
buffer = self._get(object_name)
|
||||
if buffer is None:
|
||||
return None
|
||||
# Convert buffer to Annotation
|
||||
try:
|
||||
data_str = buffer.read().decode(self.encoding)
|
||||
data = json.loads(data_str)
|
||||
annotation = Annotation(
|
||||
angle=AngleEnum(data["angle"]) if data["angle"] is not None else None,
|
||||
angle_uncertainty=bool(data["angle_uncertainty"]),
|
||||
colour=ColourEnum(data["colour"])
|
||||
if data["colour"] is not None
|
||||
else None,
|
||||
colour_uncertainty=bool(data["colour_uncertainty"]),
|
||||
contact=ContactEnum(data["contact"])
|
||||
if data["contact"] is not None
|
||||
else None,
|
||||
contact_uncertainty=bool(data["contact_uncertainty"]),
|
||||
depth=DepthEnum(data["depth"]) if data["depth"] is not None else None,
|
||||
depth_uncertainty=bool(data["depth_uncertainty"]),
|
||||
distance=DistanceEnum(data["distance"])
|
||||
if data["distance"] is not None
|
||||
else None,
|
||||
distance_uncertainty=bool(data["distance_uncertainty"]),
|
||||
lighting=LightingEnum(data["lighting"])
|
||||
if data["lighting"] is not None
|
||||
else None,
|
||||
lighting_uncertainty=bool(data["lighting_uncertainty"]),
|
||||
point_of_view=PointOfViewEnum(data["point_of_view"])
|
||||
if data["point_of_view"] is not None
|
||||
else None,
|
||||
point_of_view_uncertainty=bool(data["point_of_view_uncertainty"]),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
self.logger.error(
|
||||
"Failed to parse Annotation from data",
|
||||
object_name=object_name,
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
return annotation
|
||||
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
"""Update or create an Annotation."""
|
||||
# Check inputs
|
||||
if not isinstance(annotation, Annotation):
|
||||
raise ValueError("annotation must be an Annotation instance.")
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Convert Annotation to json
|
||||
data_json = annotation.model_dump(mode="json")
|
||||
data_bytes = json.dumps(data_json).encode(self.encoding)
|
||||
buffer = BytesIO(data_bytes)
|
||||
# Put object
|
||||
object_name = self._object_name(job_id)
|
||||
self._put(object_name, buffer, self.content_type)
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
"""Delete an Annotation by its job ID."""
|
||||
# Check input
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Delete object
|
||||
object_name = self._object_name(job_id)
|
||||
self._delete(object_name)
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all job IDs in the repository."""
|
||||
# List objects in the bucket
|
||||
objects = self._list_objects(prefix=self.folder_name + "/")
|
||||
# Stop early if no objects
|
||||
if len(objects) == 0:
|
||||
return []
|
||||
# Remove folder prefix and file extension
|
||||
object_stems = [Path(obj).stem for obj in objects]
|
||||
# Convert to list of UUIDs
|
||||
job_ids = [UUID(name) for name in object_stems]
|
||||
return job_ids
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Definition of ImageRepository class."""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from uuid import UUID
|
||||
from PIL import Image
|
||||
|
||||
from python_repositories.adapters import MinioAdapter
|
||||
|
||||
from data_store.interfaces import ImageRepositoryInterface
|
||||
|
||||
|
||||
class ImageRepository(MinioAdapter, ImageRepositoryInterface):
|
||||
"""ImageRepository implementation using MinIO as the backend."""
|
||||
|
||||
image_format: str = "PNG"
|
||||
content_type: str = "image/png"
|
||||
folder_name: str = "images"
|
||||
|
||||
@classmethod
|
||||
def _object_name(cls, image_id: UUID) -> str:
|
||||
"""Generate the object name for a given image ID."""
|
||||
# Check input
|
||||
if not isinstance(image_id, UUID):
|
||||
raise ValueError("image_id must be a valid UUID.")
|
||||
# Return object name
|
||||
return f"{cls.folder_name}/{image_id}.{cls.image_format.lower()}"
|
||||
|
||||
def __enter__(self) -> ImageRepository:
|
||||
"""Enter the runtime context related to this object."""
|
||||
super().__enter__()
|
||||
return self
|
||||
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
"""Retrieve an Image by its ID."""
|
||||
# Check input
|
||||
if not isinstance(image_id, UUID):
|
||||
raise ValueError("image_id must be a valid UUID.")
|
||||
# Get object
|
||||
object_name = self._object_name(image_id)
|
||||
buffer = self._get(object_name)
|
||||
if buffer is None:
|
||||
return None
|
||||
# Convert bytes back to PIL Image
|
||||
image: Image.Image = Image.open(buffer, formats=[self.image_format])
|
||||
return image
|
||||
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
"""Update or create an Image."""
|
||||
# Check inputs
|
||||
if not isinstance(image, Image.Image):
|
||||
raise ValueError("image must be a PIL Image instance.")
|
||||
if not isinstance(image_id, UUID):
|
||||
raise ValueError("image_id must be a valid UUID.")
|
||||
# Convert image to bytes
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, self.image_format)
|
||||
# Put object
|
||||
object_name = self._object_name(image_id)
|
||||
self._put(object_name, buffer, self.content_type)
|
||||
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
"""Delete an Image by its ID."""
|
||||
# Check input
|
||||
if not isinstance(image_id, UUID):
|
||||
raise ValueError("image_id must be a valid UUID.")
|
||||
# Delete object
|
||||
object_name = self._object_name(image_id)
|
||||
self._delete(object_name)
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all image IDs in the repository."""
|
||||
# List objects in the bucket
|
||||
objects = self._list_objects(prefix=self.folder_name + "/")
|
||||
# Stop early if no objects
|
||||
if len(objects) == 0:
|
||||
return []
|
||||
# Remove folder prefix and file extension
|
||||
object_stems = [Path(obj).stem for obj in objects]
|
||||
# Convert to list of UUIDs
|
||||
image_ids = [UUID(name) for name in object_stems]
|
||||
return image_ids
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Definition of JobRepository class."""
|
||||
|
||||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
from python_repositories.adapters import MinioAdapter
|
||||
from data_store.interfaces import JobRepositoryInterface
|
||||
from data_store.repositories import AnnotationRepository, ImageRepository
|
||||
from data_store.dto import Job
|
||||
|
||||
|
||||
class JobRepository(MinioAdapter, JobRepositoryInterface):
|
||||
"""JobRepository implementation using MinIO as the backend."""
|
||||
|
||||
def __enter__(self) -> JobRepository:
|
||||
"""Enter the runtime context related to this object."""
|
||||
super().__enter__()
|
||||
return self
|
||||
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
"""Retrieve a Job by its ID."""
|
||||
# Check input
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Get image
|
||||
with ImageRepository() as image_repo:
|
||||
image = image_repo.get(job_id)
|
||||
if image is None:
|
||||
self.logger.warning(f"Image for job ID {job_id} not found.")
|
||||
return None
|
||||
# Get annotation
|
||||
with AnnotationRepository() as annotation_repo:
|
||||
annotation = annotation_repo.get(job_id)
|
||||
if annotation is None:
|
||||
self.logger.warning(f"Annotation for job ID {job_id} not found.")
|
||||
return None
|
||||
# Create Job DTO
|
||||
job = Job(
|
||||
job_id=job_id,
|
||||
image=image,
|
||||
annotation=annotation,
|
||||
)
|
||||
return job
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
"""Update or create a Job."""
|
||||
# Check input
|
||||
if not isinstance(job, Job):
|
||||
raise ValueError("job must be a Job instance.")
|
||||
# Store image
|
||||
with ImageRepository() as image_repo:
|
||||
image_repo.put(job.image, job.job_id)
|
||||
# Store annotation
|
||||
with AnnotationRepository() as annotation_repo:
|
||||
annotation_repo.put(job.annotation, job.job_id)
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
"""Delete a Job by its ID."""
|
||||
# Check input
|
||||
if not isinstance(job_id, UUID):
|
||||
raise ValueError("job_id must be a valid UUID.")
|
||||
# Delete image
|
||||
with ImageRepository() as image_repo:
|
||||
image_repo.delete(job_id)
|
||||
# Delete annotation
|
||||
with AnnotationRepository() as annotation_repo:
|
||||
annotation_repo.delete(job_id)
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
"""List all Job IDs."""
|
||||
# List image IDs
|
||||
with ImageRepository() as image_repo:
|
||||
image_ids = set(image_repo.list_all())
|
||||
# List annotation IDs
|
||||
with AnnotationRepository() as annotation_repo:
|
||||
annotation_ids = set(annotation_repo.list_all())
|
||||
# Find intersection of IDs
|
||||
job_ids = list(image_ids.intersection(annotation_ids))
|
||||
return job_ids
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"angle": {
|
||||
"In the context of visual angle in the Kress and van Leeuwen framework, analyze the image to determine the angle of the subject.": 0.425531914893617,
|
||||
"\"Analyze the image to determine the visual angle of the subject in the Kress and van Leeuwen framework.\"": 0.46808510638297873,
|
||||
"Refined Prompt: \"In the context of visual angle analysis in the Kress and van Leeuwen framework, please provide the angle of the subject depicted in the image.\"": 0.44680851063829785,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the visual angle of the subject depicted.\"": 0.5106382978723404,
|
||||
"\"In the context of visual angle analysis in the Kress and van Leeuwen framework, please provide the angle of the subject depicted in the image.\"": 0.46808510638297873,
|
||||
"\"Refine the prompt by removing unnecessary words and focusing on the task of providing the visual angle of the subject in the image using the Kress and van Leeuwen framework.\"": 0.46808510638297873,
|
||||
"Refined Prompt: \"Provide the visual angle of the subject depicted in the image using the Kress and van Leeuwen framework.\"": 0.425531914893617,
|
||||
"\"Provide the visual angle of the subject depicted in the image using the Kress and van Leeuwen framework.\"": 0.3404255319148936,
|
||||
"\"In the context of visual angle analysis in the Kress and van Leeuwen framework, please provide the exact angle measurement of the subject depicted in the image.\"": 0.5106382978723404,
|
||||
"\"Refine the prompt by removing unnecessary words and focusing on providing the exact angle measurement of the subject in the image using the Kress and van Leeuwen framework.\"": 0.5106382978723404,
|
||||
"\"Provide the exact visual angle measurement of the subject depicted in the image using the Kress and van Leeuwen framework.\"": 0.425531914893617,
|
||||
"Refine the prompt by removing unnecessary words and focusing on providing the exact angle measurement of the subject in the image using the Kress and van Leeuwen framework.": 0.5106382978723404
|
||||
},
|
||||
"colour": {
|
||||
"In the context of colour in the Kress and van Leeuwen framework, analyze the image to determine the colour saturation of the whole image.": 0.3220338983050847,
|
||||
"\"Analyze the image for colour saturation using the Kress and van Leeuwen framework, considering the entire image.\"": 0.3220338983050847,
|
||||
"To ensure accuracy in the analysis of colour saturation using the Kress and van Leeuwen framework for the entire image, please provide a prompt that clearly and succinctly requests this information.": 0.3050847457627119,
|
||||
"\"Using the Kress and van Leeuwen framework, please provide an analysis of the colour saturation for the entire image.\"": 0.3220338983050847,
|
||||
"\"Using the Kress and van Leeuwen framework, please provide an analysis of the color saturation for the entire image.\"": 0.288135593220339,
|
||||
"Using the Kress and van Leeuwen framework, please provide an analysis of the color saturation for the entire image.": 0.3898305084745763,
|
||||
"To ensure accuracy in the analysis of color saturation using the Kress and van Leeuwen framework for the entire image, please provide a prompt that clearly and succinctly requests this information.": 0.2711864406779661
|
||||
},
|
||||
"contact": {
|
||||
"In the context of contact in the Kress and van Leeuwen framework, analyze the image to determine the contact between subject and viewer.": 0.6363636363636364,
|
||||
"\"Analyze the image to determine the contact between the subject and the viewer in the context of the Kress and van Leeuwen framework.\"": 0.3181818181818182,
|
||||
"\"Evaluate the contact between the subject and viewer in the context of the Kress and van Leeuwen framework by analyzing this image.\"": 0.5454545454545454,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the contact between the subject and viewer.\"": 0.4090909090909091,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze this image to determine the contact between the subject and viewer.\"": 0.6818181818181818,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework to determine the contact between the subject and viewer.\"": 0.7272727272727273,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the level of contact between the subject and viewer.\"": 0.5909090909090909,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework to determine the level of contact between the subject and viewer.\"": 0.45454545454545453,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze this image to determine the level of contact between the subject and viewer.\"": 0.5454545454545454,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework by evaluating the contact between the subject and viewer.\"": 0.4090909090909091,
|
||||
"Refined prompt: \"Analyze this image using the Kress and van Leeuwen framework by evaluating the level of contact between the subject and viewer.\"": 0.5,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework by evaluating the level of contact between the subject and viewer.\"": 0.5,
|
||||
"\"Evaluate the level of contact between the subject and viewer in the context of the Kress and van Leeuwen framework by analyzing this image.\"": 0.5
|
||||
},
|
||||
"depth": {
|
||||
"In the context of depth in the Kress and van Leeuwen framework, analyze the image to determine the depth of the whole image.": 0.3793103448275862,
|
||||
"\"Analyze the image to determine the depth of the scene in terms of foreground, midground, and background elements using the Kress and van Leeuwen framework.\"": 0.4482758620689655,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the depth of the foreground, midground, and background elements.\"": 0.4482758620689655,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the depth of each element in the scene, including foreground, midground, and background elements.\"": 0.4482758620689655,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze the image to determine the depth of each element in the scene, including foreground, midground, and background elements.\"": 0.39655172413793105,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze the depth of each element in the image, including foreground, midground, and background elements.\"": 0.39655172413793105
|
||||
},
|
||||
"distance": {
|
||||
"In the context of spatial distance in the Kress and van Leeuwen framework, analyze the image to determine the spatial distance that the subject is portrayed at.": 0.4,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the spatial distance portrayed for the subject.\"": 0.37142857142857144,
|
||||
"\"Using the Kress and van Leeuwen framework, determine the spatial distance portrayed for the subject in this image.\"": 0.4,
|
||||
"The refined prompt is: \"Analyze the image using the Kress and van Leeuwen framework to determine the spatial distance portrayed for the subject.\"": 0.4857142857142857,
|
||||
"The refined prompt is: \"Determine the spatial distance portrayed for the subject in this image using the Kress and van Leeuwen framework.\"": 0.4857142857142857,
|
||||
"The refined prompt is: \"Using the Kress and van Leeuwen framework, analyze the image to determine the spatial distance portrayed for the subject.\"": 0.6,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze this image to determine the spatial distance portrayed for the subject.\"": 0.5428571428571428,
|
||||
"The refined prompt is: \"Analyze this image using the Kress and van Leeuwen framework to determine the spatial distance portrayed for the subject.\"": 0.4857142857142857,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework to determine the spatial distance portrayed for the subject.\"": 0.42857142857142855,
|
||||
"\"Determine the spatial distance portrayed for the subject in this image using the Kress and van Leeuwen framework.\"": 0.4857142857142857
|
||||
},
|
||||
"lighting": {
|
||||
"In the context of lighting in the Kress and van Leeuwen framework, analyze the image to determine the lighting of the whole image.": 0.3333333333333333,
|
||||
"Refined Prompt: \"Analyze the image for its overall lighting using the Kress and van Leeuwen framework, and provide an accuracy estimate of the analysis.\"": 0.4074074074074074,
|
||||
"\"Analyze the image for its overall lighting using the Kress and van Leeuwen framework, and provide an accuracy estimate of the analysis.\"": 0.35185185185185186,
|
||||
"\"In the context of the Kress and van Leeuwen framework, analyze the image for its overall lighting and provide an accuracy estimate of the analysis.\"": 0.4074074074074074,
|
||||
"\"Analyze the image for its overall lighting using the Kress and van Leeuwen framework and provide an accuracy estimate of the analysis.\"": 0.24074074074074073,
|
||||
"\"Refined Prompt: \\\"Analyze the image for its overall lighting using the Kress and van Leeuwen framework, providing a detailed description of the lighting conditions and an accuracy estimate of the analysis.\\\"\"": 0.35185185185185186,
|
||||
"\"Analyze the image for its overall lighting using the Kress and van Leeuwen framework, providing a detailed description of the lighting conditions and an accuracy estimate of the analysis.\"": 0.3148148148148148,
|
||||
"\"Refine the prompt for analyzing the image's overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the lighting conditions and an accuracy estimate of the analysis.\"": 0.3148148148148148,
|
||||
"Refine the prompt for analyzing the image's overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the lighting conditions and an accuracy estimate of the analysis.": 0.18518518518518517,
|
||||
"\"Analyze the image for its overall lighting conditions using the Kress and van Leeuwen framework, providing a detailed description of the light sources, their intensities, and the resulting illumination, along with an accuracy estimate of the analysis.\"": 0.2962962962962963,
|
||||
"Refine the prompt for analyzing the image's overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination, along with an accuracy estimate of the analysis.": 0.18518518518518517,
|
||||
"\"Analyze the image for its overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination, along with an accuracy estimate of the analysis.\"": 0.2222222222222222,
|
||||
"\"Refine the prompt for analyzing the image's overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination, along with an accuracy estimate of the analysis.\"": 0.3888888888888889,
|
||||
"\"Analyze the image for its overall lighting conditions using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination, along with an accuracy estimate of the analysis.\"": 0.2037037037037037,
|
||||
"To improve the accuracy of analyzing the image's overall lighting using the Kress and van Leeuwen framework, provide a detailed description of the light sources, their intensities, and the resulting illumination in the image, along with an estimated accuracy for the analysis.": 0.16666666666666666,
|
||||
"\"Analyze the image for its overall lighting conditions using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination in the image, along with an estimated accuracy of the analysis.\"": 0.35185185185185186,
|
||||
"\"Provide a detailed description of the light sources, their intensities, and the resulting illumination in the image using the Kress and van Leeuwen framework, along with an estimated accuracy for the analysis.\"": 0.2037037037037037,
|
||||
"\"Analyze the image for its overall lighting conditions using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination in the image, along with an estimated accuracy for the analysis.\"": 0.37037037037037035,
|
||||
"Refine the prompt for analyzing the image's overall lighting using the Kress and van Leeuwen framework by providing a detailed description of the light sources, their intensities, and the resulting illumination in the image, along with an estimated accuracy for the analysis.": 0.25925925925925924
|
||||
},
|
||||
"point_of_view": {
|
||||
"In the context of point of view in the Kress and van Leeuwen framework, analyze the image to determine the orientation of the subject in relation to the viewer.": 0.64,
|
||||
"Refined Prompt: \"Analyze the image using the Kress and van Leeuwen framework to determine the orientation of the subject in relation to the viewer's point of view.\"": 0.72,
|
||||
"\"Using the Kress and van Leeuwen framework, analyze the image to determine the subject's orientation in relation to the viewer's point of view.\"": 0.6,
|
||||
"Refined Prompt: \"Using the Kress and van Leeuwen framework, analyze the image to determine the subject's orientation in relation to the viewer's point of view.\"": 0.72,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the subject's orientation in relation to the viewer's point of view.\"": 0.64,
|
||||
"\"In the context of point of view in the Kress and van Leeuwen framework, provide an analysis of the image to determine the orientation of the subject in relation to the viewer's perspective.\"": 0.52,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the orientation of the subject in relation to the viewer's point of view.\"": 0.56,
|
||||
"\"Using the Kress and van Leeuwen framework, provide an analysis of the image to determine the orientation of the subject in relation to the viewer's point of view.\"": 0.68,
|
||||
"\"Using the Kress and van Leeuwen framework, provide an analysis of the image to determine the subject's orientation in relation to the viewer's point of view.\"": 0.76,
|
||||
"\"Using the Kress and van Leeuwen framework, provide a detailed analysis of the image to determine the orientation of the subject in relation to the viewer's point of view.\"": 0.68
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"angle": {
|
||||
"In the context of visual angle in the Kress and van Leeuwen framework, analyze the image to determine the angle of the subject.": 0.3333333333333333,
|
||||
"\"Analyze the image to determine the visual angle of the subject using the Kress and van Leeuwen framework, providing an accuracy of at least 0.4.\"": 0.16666666666666666,
|
||||
"\"Analyze the image provided within the context of the Kress and van Leeuwen framework to accurately determine the visual angle of the subject, with an accuracy target of at least 0.4.\"": 0.3333333333333333,
|
||||
"\"Analyze the image provided using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with an accuracy target of at least 0.4.\"": 0.0,
|
||||
"\"Analyze the image provided within the context of the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with an accuracy target of at least 0.4.\"": 0.5,
|
||||
"\"Analyze the image provided using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with a minimum accuracy target of 0.4.\"": 0.16666666666666666,
|
||||
"Refined Prompt: \"Analyze the provided image using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with a minimum accuracy target of 0.4.\"": 0.16666666666666666,
|
||||
"\"Analyze the provided image using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with a minimum accuracy target of 0.4.\"": 0.16666666666666666,
|
||||
"\"Analyze the provided image using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject, targeting an accuracy of at least 0.5.\"": 0.0,
|
||||
"\"Analyze the provided image using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject with a minimum accuracy target of 0.5.\"": 0.0,
|
||||
"\"Analyze the provided image using the Kress and van Leeuwen framework to accurately determine the visual angle of the subject, targeting an accuracy of at least 0.4.\"": 0.6666666666666666
|
||||
},
|
||||
"colour": {
|
||||
"In the context of colour in the Kress and van Leeuwen framework, analyze the image to determine the colour saturation of the whole image.": 0.2857142857142857,
|
||||
"\"Analyze the image for color saturation using the Kress and van Leeuwen framework.\"": 0.42857142857142855,
|
||||
"\"Analyze the image for color saturation using the Kress and van Leeuwen framework by considering all hues present in the image.\"": 0.2857142857142857,
|
||||
"\"Using the Kress and van Leeuwen framework for color analysis, determine the overall saturation level of all hues present in the image.\"": 0.2857142857142857,
|
||||
"\"Analyze the image for color saturation using the Kress and van Leeuwen framework by considering all hues present in the image and provide a numerical value representing the overall saturation level.\"": 0.5714285714285714,
|
||||
"\"Using the Kress and van Leeuwen framework for color analysis, provide a numerical value representing the overall saturation level of all hues present in the image.\"": 0.2857142857142857,
|
||||
"\"Using the Kress and van Leeuwen framework for color analysis, determine the overall saturation level of all hues present in the image by considering their respective saturation values.\"": 0.14285714285714285,
|
||||
"\"Using the Kress and van Leeuwen framework for color analysis, provide a numerical value representing the overall saturation level of all hues present in the image by considering their respective saturation values.\"": 0.42857142857142855,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework for color analysis by considering all hues present in the image to determine the overall saturation level as a numerical value.\"": 0.42857142857142855,
|
||||
"\"Using the Kress and van Leeuwen framework for color analysis, determine the overall saturation level of all hues present in the image by considering their respective saturation values and provide a numerical value representing the overall saturation level.\"": 0.8571428571428571
|
||||
},
|
||||
"contact": {
|
||||
"In the context of contact in the Kress and van Leeuwen framework, analyze the image to determine the contact between subject and viewer.": 0.5,
|
||||
"\"Analyze the image in terms of the Kress and van Leeuwen framework by considering the contact between the subject and viewer. Please provide a detailed explanation of the contact points and their significance.\"": 0.0,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework, focusing on the contact between the subject and viewer. Provide a detailed explanation of the contact points and their significance.\"": 1.0
|
||||
},
|
||||
"depth": {
|
||||
"In the context of depth in the Kress and van Leeuwen framework, analyze the image to determine the depth of the whole image.": 0.7142857142857143,
|
||||
"\"Analyze the image in the context of depth according to the Kress and van Leeuwen framework and provide an accurate assessment of the overall depth of the image.\"": 0.7142857142857143,
|
||||
"\"Analyze the image for depth using the Kress and van Leeuwen framework and provide a precise measurement of the overall depth.\"": 0.5714285714285714,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to provide an accurate measurement of its overall depth.\"": 0.8571428571428571,
|
||||
"To improve the accuracy of depth measurement using Kress and van Leeuwen framework, please provide a precise analysis of the image's overall depth based on this framework.": 0.5714285714285714,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework and provide a precise measurement of its overall depth.\"": 0.8571428571428571,
|
||||
"\"Analyze the image for depth using the Kress and van Leeuwen framework and provide a precise measurement of its overall depth.\"": 0.5714285714285714,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to provide a precise measurement of its overall depth.\"": 0.2857142857142857,
|
||||
"To ensure accurate assessment of depth in the image using the Kress and van Leeuwen framework, please analyze the image for depth and provide a precise measurement based on this framework.": 0.42857142857142855,
|
||||
"To provide an accurate measurement of the overall depth of the image using the Kress and van Leeuwen framework, please analyze the image for depth and provide a precise assessment based on this framework.": 0.2857142857142857
|
||||
},
|
||||
"distance": {
|
||||
"In the context of spatial distance in the Kress and van Leeuwen framework, analyze the image to determine the spatial distance that the subject is portrayed at.": 0.5714285714285714,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the spatial distance at which the subject is portrayed.\"": 1.0
|
||||
},
|
||||
"lighting": {
|
||||
"In the context of lighting in the Kress and van Leeuwen framework, analyze the image to determine the lighting of the whole image.": 0.14285714285714285,
|
||||
"\"Analyze the image to determine the lighting conditions present in the scene using the Kress and van Leeuwen framework.\"": 0.42857142857142855,
|
||||
"Refined Prompt: \"Analyze the image using the Kress and van Leeuwen framework to determine the lighting conditions present in the scene.\"": 0.2857142857142857,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the lighting conditions present in the scene.\"": 0.8571428571428571,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the type of lighting present in the scene.\"": 0.2857142857142857,
|
||||
"Refined Prompt: \"Analyze the image using the Kress and van Leeuwen framework to determine the type of lighting present in the scene.\"": 0.2857142857142857
|
||||
},
|
||||
"point_of_view": {
|
||||
"In the context of point of view in the Kress and van Leeuwen framework, analyze the image to determine the orientation of the subject in relation to the viewer.": 0.3333333333333333,
|
||||
"\"Analyze the image to determine the orientation of the subject in relation to the viewer using the Kress and van Leeuwen framework.\"": 0.6666666666666666,
|
||||
"Refined Prompt: \"Using the Kress and van Leeuwen framework, determine the orientation of the subject in relation to the viewer by analyzing this image.\"": 0.0,
|
||||
"\"Analyze the image using the Kress and van Leeuwen framework to determine the orientation of the subject in relation to the viewer.\"": 0.3333333333333333,
|
||||
"\"Using the Kress and van Leeuwen framework, determine the orientation of the subject in relation to the viewer by analyzing this image.\"": 1.0,
|
||||
"\"Analyze this image using the Kress and van Leeuwen framework to determine the orientation of the subject in relation to the viewer.\"": 0.6666666666666666
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
[project]
|
||||
name = "visual-semiotic-ai-analysis"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
authors = [
|
||||
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"mlflow>=3.4.0",
|
||||
"pandas>=2.3.2",
|
||||
"pillow>=11.3.0",
|
||||
"pydantic>=2.11.9",
|
||||
"python-repositories[minio]>=0.2.0",
|
||||
"python-utils>=0.1.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = [
|
||||
"data_store",
|
||||
]
|
||||
module-root = "."
|
||||
|
||||
[tool.uv.sources]
|
||||
python-repositories = { index = "gitea" }
|
||||
python-utils = { index = "gitea" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "threadripper-proxpi-cache"
|
||||
url = "http://10.0.0.2:5001/index/"
|
||||
default = true
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "gitea"
|
||||
url = "https://gitea.gt-proj.com/api/packages/brian/pypi/simple/"
|
||||
explicit = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true # nudge to use stricter types
|
||||
warn_unused_configs = true # nudge to remove unused configs
|
||||
disallow_untyped_defs = true # disallow untyped function definitions
|
||||
disallow_incomplete_defs = true # ensure all parts of a function has type annotation
|
||||
check_untyped_defs = true # dont skip untyped functions
|
||||
disallow_untyped_decorators = false # allow untyped decorators to keep code more readable
|
||||
no_implicit_optional = true # disallow implicit optional types leading to None-mess
|
||||
warn_redundant_casts = true # nudge to use explicit type casts
|
||||
warn_unused_ignores = true # nudge to remove unused ignores
|
||||
warn_no_return = true # catch missing return statements
|
||||
warn_unreachable = true # catch unreachable code
|
||||
show_error_codes = true # show error codes in output
|
||||
explicit_package_bases = true # reduce risk of import confusion
|
||||
namespace_packages = true # enable namespace packages
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "testcontainers.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "minio.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.18.1",
|
||||
"pandas-stubs>=2.3.2.250827",
|
||||
"pre-commit>=4.3.0",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-cov>=7.0.0",
|
||||
"python-dotenv>=1.1.1",
|
||||
"pyupgrade>=3.20.0",
|
||||
"ruff>=0.13.0",
|
||||
"safety>=3.2.11",
|
||||
"testcontainers>=4.13.0",
|
||||
"types-requests>=2.32.4.20250913",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
"""Script to query an AI model with a prompt and image and print the response."""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import json
|
||||
from io import BytesIO
|
||||
import requests
|
||||
import structlog
|
||||
from dotenv import load_dotenv
|
||||
from PIL import Image
|
||||
from datetime import datetime
|
||||
import mlflow
|
||||
from python_utils import check_env
|
||||
from data_store.repositories import JobRepository
|
||||
from data_store.dto import (
|
||||
AngleEnum,
|
||||
ColourEnum,
|
||||
ContactEnum,
|
||||
DepthEnum,
|
||||
DistanceEnum,
|
||||
LightingEnum,
|
||||
PointOfViewEnum,
|
||||
)
|
||||
|
||||
RUN_NAME = f"query-ai-model_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
IMAGE_MODEL = "llava:13b"
|
||||
PROMPT_MODEL = "mistral-small:22b-instruct-2409-q4_K_M"
|
||||
CATEGORY_PROMPT_MAP = {
|
||||
"angle": (
|
||||
"In the context of visual angle in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the angle of the subject."
|
||||
),
|
||||
"colour": (
|
||||
"In the context of colour in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the colour saturation of the whole image."
|
||||
),
|
||||
"contact": (
|
||||
"In the context of contact in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the contact between subject and viewer."
|
||||
),
|
||||
"depth": (
|
||||
"In the context of depth in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the depth of the whole image."
|
||||
),
|
||||
"distance": (
|
||||
"In the context of spatial distance in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the spatial distance that the subject is portrayed at."
|
||||
),
|
||||
"lighting": (
|
||||
"In the context of lighting in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the lighting of the whole image."
|
||||
),
|
||||
"point_of_view": (
|
||||
"In the context of point of view in the Kress and van Leeuwen framework, "
|
||||
"analyze the image to determine the orientation of the subject in relation to the viewer."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def query_model_with_image(
|
||||
prompt: str,
|
||||
allowed_answers: list[str],
|
||||
image: Image.Image,
|
||||
model: str = "llava:13b",
|
||||
) -> str:
|
||||
"""
|
||||
Query an Ollama instance with a prompt, image, and constrained answers.
|
||||
|
||||
Args:
|
||||
prompt: The text prompt to send
|
||||
allowed_answers: List of valid response options
|
||||
image: PIL Image to analyze
|
||||
model: The Ollama model to use (default: "llava:13b")
|
||||
|
||||
Returns:
|
||||
The model's response text
|
||||
"""
|
||||
# Ensure required env vars are set
|
||||
check_env("OLLAMA_ENDPOINT")
|
||||
# Read environment variables
|
||||
ollama_endpoint = str(os.getenv("OLLAMA_ENDPOINT"))
|
||||
# Handle RGBA images by converting to RGB
|
||||
if image.mode == "RGBA":
|
||||
# Convert RGBA to RGB by compositing against white background
|
||||
rgb_image = Image.new("RGB", image.size, (255, 255, 255))
|
||||
rgb_image.paste(image, mask=image.split()[-1]) # Use alpha as mask
|
||||
image = rgb_image
|
||||
# Convert PIL Image to base64
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG")
|
||||
image_b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
# Build prompt with allowed answers
|
||||
prompt_with_options = (
|
||||
f"{prompt}\n"
|
||||
f"Please choose one of the following options: {', '.join(allowed_answers)}.\n"
|
||||
"Respond with only the chosen option."
|
||||
)
|
||||
# Prepare request payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt_with_options,
|
||||
"images": [image_b64],
|
||||
"stream": False,
|
||||
}
|
||||
# Make request to Ollama
|
||||
# N.B. the generate endpoint does not retain context between calls
|
||||
response = requests.post(
|
||||
f"{ollama_endpoint}/api/generate", json=payload, timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return str(result.get("response", "").strip())
|
||||
|
||||
|
||||
def score_response(response: str, expected_answer: str) -> int:
|
||||
"""
|
||||
Score the model's response based on allowed answers.
|
||||
|
||||
Args:
|
||||
response: The model's response text
|
||||
expected_answer: The expected answer
|
||||
|
||||
Returns:
|
||||
1 if the response is in allowed answers, else 0
|
||||
"""
|
||||
# Score response
|
||||
return 1 if response.lower() == expected_answer.lower() else 0
|
||||
|
||||
|
||||
def allowed_answers_for_category(
|
||||
category: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get allowed answers based on the category.
|
||||
|
||||
Args:
|
||||
category: The category to determine allowed answers
|
||||
|
||||
Returns:
|
||||
List of allowed answers
|
||||
"""
|
||||
match category.lower():
|
||||
case "angle":
|
||||
return [e.value for e in AngleEnum]
|
||||
case "colour":
|
||||
return [e.value for e in ColourEnum]
|
||||
case "contact":
|
||||
return [e.value for e in ContactEnum]
|
||||
case "depth":
|
||||
return [e.value for e in DepthEnum]
|
||||
case "distance":
|
||||
return [e.value for e in DistanceEnum]
|
||||
case "lighting":
|
||||
return [e.value for e in LightingEnum]
|
||||
case "point_of_view":
|
||||
return [e.value for e in PointOfViewEnum]
|
||||
case _:
|
||||
raise ValueError(f"Invalid category: {category}")
|
||||
|
||||
|
||||
def calculate_model_prompt_accuracy(
|
||||
model: str,
|
||||
prompt: str,
|
||||
category: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the accuracy of the prompt against the expected answers for the given category.
|
||||
"""
|
||||
# Determine allowed answers based on category
|
||||
allowed_answers = allowed_answers_for_category(category)
|
||||
# Score each job in the repository)
|
||||
jobs_skipped = 0
|
||||
total_score = 0
|
||||
with JobRepository() as job_repo:
|
||||
job_ids = job_repo.list_all()
|
||||
if not job_ids:
|
||||
raise ValueError("No jobs found in JobRepository.")
|
||||
total_jobs = len(job_ids)
|
||||
# Get all responses for the prompt
|
||||
for job_id in job_ids:
|
||||
job = job_repo.get(job_id)
|
||||
if not job:
|
||||
jobs_skipped += 1
|
||||
continue
|
||||
# skip job if uncertainty flag is set for the category
|
||||
if getattr(job.annotation, f"{category.lower()}_uncertainty"):
|
||||
print(f"Job {job_id}: Skipping due to uncertainty flag.")
|
||||
jobs_skipped += 1
|
||||
continue
|
||||
response = query_model_with_image(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
allowed_answers=allowed_answers,
|
||||
image=job.image,
|
||||
)
|
||||
# Get expected answer from job annotation
|
||||
expected_answer = getattr(job.annotation, category.lower())
|
||||
# Score the response
|
||||
score = score_response(response, expected_answer)
|
||||
print(
|
||||
f"Job {job_id}: Response '{response}' - Expected answer '{expected_answer}' - Score: {score}"
|
||||
)
|
||||
total_score += score
|
||||
# Calculate accuracy
|
||||
jobs_evaluated = total_jobs - jobs_skipped
|
||||
mlflow.log_metrics(
|
||||
{
|
||||
"total_jobs": total_jobs,
|
||||
"jobs_skipped": jobs_skipped,
|
||||
"jobs_evaluated": jobs_evaluated,
|
||||
}
|
||||
)
|
||||
print(
|
||||
f"Total Jobs: {total_jobs}, Jobs Skipped: {jobs_skipped}, Jobs Evaluated: {jobs_evaluated}"
|
||||
)
|
||||
if jobs_evaluated == 0:
|
||||
raise ValueError("No jobs were evaluated. All jobs were skipped.")
|
||||
accuracy = total_score / jobs_evaluated
|
||||
return accuracy
|
||||
|
||||
|
||||
def refine_prompt(
|
||||
model: str,
|
||||
prompt_accuracy_map: dict[str, float],
|
||||
) -> str:
|
||||
"""
|
||||
Refine the prompt to improve accuracy.
|
||||
|
||||
Args:
|
||||
model: The Ollama model to use
|
||||
prompt_accuracy_map: A mapping of prompts to their accuracies
|
||||
|
||||
Returns:
|
||||
The refined prompt text
|
||||
"""
|
||||
# Ensure required env vars are set
|
||||
check_env("OLLAMA_ENDPOINT")
|
||||
# Read environment variables
|
||||
ollama_endpoint = str(os.getenv("OLLAMA_ENDPOINT"))
|
||||
# Convert prompt accuracy map to json string for better readability
|
||||
prompt_accuracy_json = json.dumps(prompt_accuracy_map, indent=2)
|
||||
# Build refinement instruction
|
||||
refinement_instruction = (
|
||||
"Given the following prompts and accuracies:\n"
|
||||
f"{prompt_accuracy_json}\n"
|
||||
"Please refine the prompt to improve the accuracy.\n"
|
||||
"The refined prompt should be clear, concise, and focused on obtaining accurate responses.\n"
|
||||
"Respond with only the refined prompt."
|
||||
)
|
||||
# Prepare request payload
|
||||
payload = {"model": model, "prompt": refinement_instruction, "stream": False}
|
||||
# Make request to Ollama
|
||||
# N.B. the generate endpoint does not retain context between calls
|
||||
response = requests.post(
|
||||
f"{ollama_endpoint}/api/generate", json=payload, timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return str(result.get("response", "").strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv()
|
||||
# Ensure required env vars are set
|
||||
check_env("OLLAMA_ENDPOINT")
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
# Set up basic logging configuration
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
# Connect to MLFlow
|
||||
mlflow.set_tracking_uri("http://10.0.0.2:5000")
|
||||
experiment = mlflow.get_experiment_by_name("visual-semiotic-ai-analysis")
|
||||
experiment_id = experiment.experiment_id if experiment else None
|
||||
# Calculate accuracy
|
||||
for category, initial_prompt in CATEGORY_PROMPT_MAP.items():
|
||||
step = 0
|
||||
run = mlflow.start_run(
|
||||
experiment_id=experiment_id,
|
||||
run_name=RUN_NAME,
|
||||
tags={
|
||||
"image_model": IMAGE_MODEL,
|
||||
"prompt_model": PROMPT_MODEL,
|
||||
"category": category,
|
||||
},
|
||||
)
|
||||
mlflow.log_param(f"step_{step}_prompt", initial_prompt)
|
||||
print(f"Calculating accuracy for category: {category}")
|
||||
accuracy = calculate_model_prompt_accuracy(IMAGE_MODEL, initial_prompt, category)
|
||||
mlflow.log_metric("accuracy", accuracy, step=step)
|
||||
# Log to MLFlow
|
||||
prompt_accuracy_map = {initial_prompt: accuracy}
|
||||
print(f"Prompt accuracy for category '{category}': {accuracy:.2%}")
|
||||
# Loop to refine prompt based on accuracy
|
||||
for i in range(99):
|
||||
step += 1
|
||||
try:
|
||||
# Generate refined prompt
|
||||
refined_prompt = refine_prompt(PROMPT_MODEL, prompt_accuracy_map)
|
||||
mlflow.log_param(f"step_{step}_prompt", refined_prompt)
|
||||
print(f"Refined prompt: {refined_prompt}")
|
||||
except Exception as e:
|
||||
print(f"Error refining prompt: {e}")
|
||||
continue
|
||||
try:
|
||||
# Calculate accuracy for refined prompt
|
||||
accuracy = calculate_model_prompt_accuracy(
|
||||
IMAGE_MODEL, refined_prompt, category
|
||||
)
|
||||
mlflow.log_metric("accuracy", accuracy, step=step)
|
||||
except Exception as e:
|
||||
print(f"Error calculating accuracy: {e}")
|
||||
continue
|
||||
# Update prompt accuracy map
|
||||
# prompt_accuracy_map[refined_prompt] = accuracy
|
||||
print(f"Prompt accuracy for category '{category}': {accuracy:.2%}")
|
||||
# Stop if accuracy is 100%
|
||||
if accuracy == 1.0:
|
||||
print(
|
||||
f"Achieved 100% accuracy for category '{category}'. Stopping refinement."
|
||||
)
|
||||
break
|
||||
mlflow.end_run()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Script to convert Label Studio output JSON files to an Annotation."""
|
||||
|
||||
import json
|
||||
from uuid import UUID
|
||||
from dotenv import load_dotenv
|
||||
from python_repositories.adapters import MinioAdapter
|
||||
from data_store.dto import Annotation
|
||||
from data_store.repositories import AnnotationRepository
|
||||
|
||||
|
||||
def list_label_studio_output_files(minio_adapter: MinioAdapter) -> list[str]:
|
||||
"""List the Label Studio output files in MinIO."""
|
||||
# Check input
|
||||
if not isinstance(minio_adapter, MinioAdapter):
|
||||
raise ValueError("minio_adapter must be an instance of MinioAdapter")
|
||||
if not minio_adapter.is_connected:
|
||||
raise ConnectionError("minio_adapter must be connected to MinIO")
|
||||
# List objects with the Label Studio output prefix
|
||||
prefix = "label-studio-output/"
|
||||
return minio_adapter._list_objects(prefix)
|
||||
|
||||
|
||||
def get_label_studio_output(minio_adapter: MinioAdapter, object_name: str) -> dict:
|
||||
"""Get a Label Studio output file from MinIO."""
|
||||
# Check input
|
||||
if not isinstance(minio_adapter, MinioAdapter):
|
||||
raise ValueError("minio_adapter must be an instance of MinioAdapter")
|
||||
if not minio_adapter.is_connected:
|
||||
raise ConnectionError("minio_adapter must be connected to MinIO")
|
||||
if not isinstance(object_name, str) or len(object_name) == 0:
|
||||
raise ValueError("object_name must be a non-empty string")
|
||||
# Get object from MinIO
|
||||
buffer = minio_adapter._get(object_name)
|
||||
if buffer is None:
|
||||
raise FileNotFoundError(f"File not found in MinIO: {object_name}")
|
||||
# Convert buffer to JSON
|
||||
data: dict = json.load(buffer)
|
||||
return data
|
||||
|
||||
|
||||
def convert_label_studio_output(label_studio_output: dict) -> tuple[Annotation, UUID]:
|
||||
"""Convert Label Studio output to Annotation and corresponding job ID."""
|
||||
# Check input
|
||||
if not isinstance(label_studio_output, dict):
|
||||
raise ValueError("label_studio_output must be a dictionary")
|
||||
# Extract job id
|
||||
image_path = label_studio_output["task"]["data"]["image"]
|
||||
job_id_str = image_path.rsplit("/")[-1].split(".")[0]
|
||||
job_id = UUID(job_id_str)
|
||||
# Extract relevant information from the Label Studio output
|
||||
annotation_data: dict[str, str | None | bool] = {}
|
||||
for elem in label_studio_output["result"]:
|
||||
# Extract key and value
|
||||
key = elem["from_name"]
|
||||
choices = elem["value"]["choices"]
|
||||
# Check for presence of uncertainty flag
|
||||
uncertainty_key = f"{key}_uncertainty"
|
||||
uncertainty_value = bool("uncertain" in choices)
|
||||
if uncertainty_value:
|
||||
choices.remove("uncertain")
|
||||
# If no choices left, set to None
|
||||
value: str | None = choices[0] if choices else None
|
||||
# Store in annotation data
|
||||
annotation_data[key] = value
|
||||
annotation_data[uncertainty_key] = uncertainty_value
|
||||
# Create the Annotation object
|
||||
annotation = Annotation(**annotation_data) # type: ignore[arg-type]
|
||||
return annotation, job_id
|
||||
|
||||
|
||||
def sync_label_studio_annotations(
|
||||
minio_adapter: MinioAdapter,
|
||||
annotation_repository: AnnotationRepository,
|
||||
) -> None:
|
||||
"""Sync Label Studio output files to AnnotationRepository."""
|
||||
# Check inputs
|
||||
if not isinstance(minio_adapter, MinioAdapter):
|
||||
raise ValueError("minio_adapter must be an instance of MinioAdapter")
|
||||
if not minio_adapter.is_connected:
|
||||
raise ConnectionError("minio_adapter must be connected to MinIO")
|
||||
if not isinstance(annotation_repository, AnnotationRepository):
|
||||
raise ValueError(
|
||||
"annotation_repository must be an instance of AnnotationRepository"
|
||||
)
|
||||
if not annotation_repository.is_connected:
|
||||
raise ConnectionError("annotation_repository must be connected to MinIO")
|
||||
# List annotations in repository
|
||||
annotation_ids = annotation_repository.list_all()
|
||||
print(f"Found {len(annotation_ids)} annotations in repository.")
|
||||
# Count Label Studio output files
|
||||
label_studio_files = list_label_studio_output_files(minio_adapter)
|
||||
print(f"Found {len(label_studio_files)} Label Studio output files.")
|
||||
# Stop early if equal number of annotations and Label Studio files
|
||||
if len(annotation_ids) == len(label_studio_files):
|
||||
print("No new Label Studio output files to process. Exiting.")
|
||||
return
|
||||
# Process each Label Studio output file
|
||||
for file_name in label_studio_files:
|
||||
print(f"Processing file: {file_name}")
|
||||
# Get Label Studio output
|
||||
try:
|
||||
label_studio_output = get_label_studio_output(
|
||||
minio_adapter,
|
||||
file_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to get Label Studio output for {file_name}: {exc}")
|
||||
continue
|
||||
# Convert to Annotation
|
||||
try:
|
||||
annotation, job_id = convert_label_studio_output(label_studio_output)
|
||||
except Exception as exc:
|
||||
print(f"Failed to convert Label Studio output for {file_name}: {exc}")
|
||||
continue
|
||||
# Store Annotation in repository
|
||||
try:
|
||||
annotation_repository.put(annotation, job_id)
|
||||
except Exception as exc:
|
||||
print(f"Failed to store annotation for {file_name}: {exc}")
|
||||
continue
|
||||
print(f"Successfully processed and stored annotation for {file_name}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
# Connect to data sources
|
||||
with MinioAdapter() as minio_adapter, AnnotationRepository() as annotation_repo:
|
||||
sync_label_studio_annotations(minio_adapter, annotation_repo)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Script to upload images to MinIO server."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from data_store.repositories import ImageRepository
|
||||
|
||||
UPLOAD_DIR = Path("/Volumes/BW-PSSD/maersk_data/images")
|
||||
|
||||
|
||||
def upload_images_to_minio(
|
||||
image_repo: ImageRepository,
|
||||
upload_dir: Path,
|
||||
) -> None:
|
||||
"""Upload images from the specified directory to MinIO server."""
|
||||
# Check input
|
||||
if not isinstance(image_repo, ImageRepository):
|
||||
raise ValueError("image_repo must be an instance of ImageRepository.")
|
||||
if not image_repo.is_connected:
|
||||
raise ConnectionError("image_repo must be connected to MinIO server.")
|
||||
if not isinstance(upload_dir, Path):
|
||||
raise ValueError("upload_dir must be a pathlib.Path instance.")
|
||||
if not upload_dir.is_dir():
|
||||
raise NotADirectoryError(f"upload_dir must be a valid directory: {upload_dir}")
|
||||
if not upload_dir.exists():
|
||||
raise FileNotFoundError(f"upload_dir does not exist: {upload_dir}")
|
||||
# Scan directory for images
|
||||
images = list(upload_dir.glob("*.png"))
|
||||
if not images:
|
||||
raise FileNotFoundError(f"No PNG images found in directory: {upload_dir}")
|
||||
print(f"Found {len(images)} images to upload.")
|
||||
# Initialize list to store image name and id mapping
|
||||
image_name_id_map: dict[str, str] = {}
|
||||
# Begin uploading images
|
||||
for image_path in images:
|
||||
try:
|
||||
# Load image
|
||||
img = Image.open(image_path)
|
||||
# Upload image to MinIO
|
||||
img_id = uuid4()
|
||||
image_repo.put(img, img_id)
|
||||
print(f"Uploaded {image_path.name} with ID {img_id}.")
|
||||
except Exception as e:
|
||||
print(f"Failed to upload {image_path.name}: {e}")
|
||||
continue
|
||||
else:
|
||||
# Store mapping
|
||||
image_name_id_map[image_path.name] = str(img_id)
|
||||
# Save mapping to CSV
|
||||
df = pd.DataFrame(
|
||||
list(image_name_id_map.items()), columns=["image_name", "image_id"]
|
||||
)
|
||||
csv_path = Path(__file__).parent / "image_name_id_map.csv"
|
||||
df.to_csv(csv_path, index=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
# Instantiate ImageRepository
|
||||
with ImageRepository() as image_repo:
|
||||
# Upload images from the specified directory
|
||||
upload_images_to_minio(image_repo, UPLOAD_DIR)
|
||||
print("Image upload process completed.")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Integration tests for AnnotationRepositoryInterface."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from data_store.interfaces import AnnotationRepositoryInterface
|
||||
from data_store.dto import Annotation
|
||||
|
||||
|
||||
def test_get_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling get on a subclass without get implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(AnnotationRepositoryInterface):
|
||||
"""A class missing the get method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
return super().get(job_id) # type: ignore
|
||||
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.get(job_id_list[0])
|
||||
|
||||
|
||||
def test_put_raises_not_implemented_if_not_overwritten(
|
||||
annotation: Annotation,
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling put on a subclass without put implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(AnnotationRepositoryInterface):
|
||||
"""A class missing the put method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
return None
|
||||
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
return super().put(annotation, job_id) # type: ignore
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.put(annotation, job_id_list[0])
|
||||
|
||||
|
||||
def test_delete_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling delete on a subclass without delete implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(AnnotationRepositoryInterface):
|
||||
"""A class missing the delete method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
return None
|
||||
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return super().delete(job_id) # type: ignore
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.delete(job_id_list[0])
|
||||
|
||||
|
||||
def test_list_all_raises_not_implemented_if_not_overwritten() -> None:
|
||||
"""Test that calling list_all on a subclass without list_all implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(AnnotationRepositoryInterface):
|
||||
"""A class missing the list_all method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Annotation | None:
|
||||
return None
|
||||
|
||||
def put(self, annotation: Annotation, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return super().list_all() # type: ignore
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.list_all()
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Integration tests for AnnotationRepository."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from data_store.repositories.annotation_repository import AnnotationRepository
|
||||
from data_store.dto import (
|
||||
Annotation,
|
||||
)
|
||||
|
||||
|
||||
def test_get_annotation(
|
||||
annotation_in_minio: tuple[UUID, Annotation],
|
||||
annotation_repository: AnnotationRepository,
|
||||
) -> None:
|
||||
"""Test retrieving an annotation from AnnotationRepository."""
|
||||
# Arrange
|
||||
job_id, expected_annotation = annotation_in_minio
|
||||
# Act
|
||||
retrieved_annotation = annotation_repository.get(job_id)
|
||||
# Assert
|
||||
assert retrieved_annotation is not None
|
||||
assert isinstance(retrieved_annotation, Annotation)
|
||||
assert retrieved_annotation == expected_annotation
|
||||
|
||||
|
||||
def test_put_annotation(
|
||||
annotation_repository: AnnotationRepository,
|
||||
annotation: Annotation,
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test storing an annotation in AnnotationRepository."""
|
||||
# Arrange
|
||||
job_id = job_id_list[0]
|
||||
assert annotation_repository.get(job_id) is None
|
||||
# Act
|
||||
annotation_repository.put(annotation, job_id)
|
||||
# Assert
|
||||
received_annotation = annotation_repository.get(job_id)
|
||||
assert received_annotation is not None
|
||||
assert isinstance(received_annotation, Annotation)
|
||||
assert received_annotation == annotation
|
||||
|
||||
|
||||
def test_delete_annotation(
|
||||
annotation_in_minio: tuple[UUID, Annotation],
|
||||
annotation_repository: AnnotationRepository,
|
||||
) -> None:
|
||||
"""Test deleting an annotation from AnnotationRepository."""
|
||||
# Arrange
|
||||
job_id, _ = annotation_in_minio
|
||||
assert annotation_repository.get(job_id) is not None
|
||||
# Act
|
||||
annotation_repository.delete(job_id)
|
||||
# Assert
|
||||
assert annotation_repository.get(job_id) is None
|
||||
|
||||
|
||||
def test_list_all_annotations(
|
||||
annotation_in_minio: tuple[UUID, Annotation],
|
||||
annotation_repository: AnnotationRepository,
|
||||
) -> None:
|
||||
"""Test listing all annotations in AnnotationRepository."""
|
||||
# Arrange
|
||||
job_id, _ = annotation_in_minio
|
||||
# Act
|
||||
annotation_ids = annotation_repository.list_all()
|
||||
# Assert
|
||||
assert isinstance(annotation_ids, list)
|
||||
assert all(isinstance(i, UUID) for i in annotation_ids)
|
||||
assert job_id in annotation_ids
|
||||
|
||||
|
||||
def test_list_all_annotations_empty(
|
||||
annotation_repository: AnnotationRepository,
|
||||
) -> None:
|
||||
"""Test listing all annotations in an empty AnnotationRepository."""
|
||||
# Act
|
||||
annotation_ids = annotation_repository.list_all()
|
||||
# Assert
|
||||
assert isinstance(annotation_ids, list)
|
||||
assert len(annotation_ids) == 0
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Integration test configuration."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from uuid import UUID
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
import pytest
|
||||
import structlog
|
||||
from minio import Minio, S3Error
|
||||
from testcontainers.minio import MinioContainer
|
||||
from python_repositories.adapters import MinioAdapter
|
||||
from data_store.repositories import (
|
||||
ImageRepository,
|
||||
AnnotationRepository,
|
||||
JobRepository,
|
||||
)
|
||||
from data_store.dto import (
|
||||
Annotation,
|
||||
AngleEnum,
|
||||
ColourEnum,
|
||||
ContactEnum,
|
||||
DepthEnum,
|
||||
DistanceEnum,
|
||||
LightingEnum,
|
||||
PointOfViewEnum,
|
||||
Job,
|
||||
)
|
||||
|
||||
# Last Docker image with a useful WebUI
|
||||
MINIO_DOCKER_IMAGE = "minio/minio:RELEASE.2023-03-20T20-16-18Z"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def configure_logging() -> None:
|
||||
"""Configure logging for the test session."""
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
# Set up basic logging configuration
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def minio_container() -> Generator[dict[str, str]]:
|
||||
"""Set up MinIO container and provide credentials for testing."""
|
||||
# Set variables
|
||||
access_key = "minioadmin"
|
||||
secret_key = "minioadmin"
|
||||
bucket_name = "test-bucket"
|
||||
# Start container
|
||||
container = MinioContainer(
|
||||
image=MINIO_DOCKER_IMAGE,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
)
|
||||
container.start()
|
||||
# Build environment variables dictionary
|
||||
minio_host = container.get_container_host_ip()
|
||||
minio_port = container.get_exposed_port(9000)
|
||||
minio_endpoint = f"{minio_host}:{minio_port}"
|
||||
env_vars = {
|
||||
"MINIO_ENDPOINT": minio_endpoint,
|
||||
"MINIO_ACCESS_KEY": access_key,
|
||||
"MINIO_SECRET_KEY": secret_key,
|
||||
"MINIO_BUCKET": bucket_name,
|
||||
}
|
||||
|
||||
yield env_vars
|
||||
|
||||
# Stop container
|
||||
container.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def set_environment_variables(
|
||||
minio_container: dict[str, str],
|
||||
) -> Generator[dict[str, str]]:
|
||||
"""Set environment variables needed for tests."""
|
||||
# Build environment variables dictionary
|
||||
env_vars = {}
|
||||
env_vars.update(minio_container)
|
||||
# Set environment variables
|
||||
for key, value in env_vars.items():
|
||||
os.environ[key] = value
|
||||
|
||||
yield env_vars
|
||||
|
||||
# Cleanup
|
||||
for key in env_vars:
|
||||
_ = os.environ.pop(key, default=None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def job_id_list() -> Generator[list[UUID]]:
|
||||
"""Provide a list of job IDs for testing."""
|
||||
yield [
|
||||
UUID("d358cbba-75be-4c38-becd-7abf338ca941"),
|
||||
UUID("56d20d25-b733-4ed3-880c-732d7257dcaa"),
|
||||
UUID("8019292f-8353-43d7-a189-076c6630de7b"),
|
||||
]
|
||||
|
||||
|
||||
# @pytest.fixture(scope="session")
|
||||
# def csv_data_file(
|
||||
# job_id_list: list[UUID],
|
||||
# ) -> Generator[Path]:
|
||||
# """Create a CSV data file for testing."""
|
||||
# # Create csv content
|
||||
# csv_data = pd.DataFrame(
|
||||
# {
|
||||
# "job_id": job_id_list,
|
||||
# "distance": [
|
||||
# "close",
|
||||
# "long",
|
||||
# "wrong",
|
||||
# ],
|
||||
# "angle": ["high", "low", "wrong"],
|
||||
# "point_of_view": ["frontal", "oblique", "wrong"],
|
||||
# "contact": ["offer", "demand", "wrong"],
|
||||
# }
|
||||
# )
|
||||
# # Create data directory and file path
|
||||
# data_dir = Path(tempfile.mkdtemp())
|
||||
# data_path = data_dir / "annotations.csv"
|
||||
# # Write CSV content to file
|
||||
# csv_data.to_csv(data_path, header=True, index=False)
|
||||
|
||||
# yield data_path
|
||||
|
||||
# # Cleanup
|
||||
# shutil.rmtree(data_dir)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def raw_minio_client(
|
||||
set_environment_variables: dict[str, str],
|
||||
) -> Generator[Minio]:
|
||||
"""Provide a MinIO client for testing."""
|
||||
# Create MinIO client
|
||||
client = Minio(
|
||||
endpoint=set_environment_variables["MINIO_ENDPOINT"],
|
||||
access_key=set_environment_variables["MINIO_ACCESS_KEY"],
|
||||
secret_key=set_environment_variables["MINIO_SECRET_KEY"],
|
||||
secure=False,
|
||||
)
|
||||
# Create bucket if it doesn't exist
|
||||
bucket_name = set_environment_variables["MINIO_BUCKET"]
|
||||
try:
|
||||
if not client.bucket_exists(bucket_name):
|
||||
client.make_bucket(bucket_name)
|
||||
except S3Error as e:
|
||||
raise RuntimeError(f"Failed to create or access bucket: {e}")
|
||||
|
||||
yield client
|
||||
|
||||
# No explicit cleanup needed for MinIO client
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def image_repository() -> Generator[ImageRepository]:
|
||||
"""Provide an ImageRepository instance for testing."""
|
||||
# Instantiate and connect repository
|
||||
repo = ImageRepository()
|
||||
repo.connect()
|
||||
|
||||
yield repo
|
||||
|
||||
# Cleanup
|
||||
repo.disconnect()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def image() -> Generator[Image.Image]:
|
||||
"""Provide a sample image for testing."""
|
||||
img = Image.new("RGB", (10, 10))
|
||||
yield img
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def image_in_minio(
|
||||
set_environment_variables: dict[str, str],
|
||||
raw_minio_client: Minio,
|
||||
image: Image.Image,
|
||||
job_id_list: list[UUID],
|
||||
) -> Generator[tuple[UUID, Image.Image]]:
|
||||
"""Upload a sample image to MinIO and provide its ID for testing."""
|
||||
# Prepare object name and buffer
|
||||
bucket_name = set_environment_variables["MINIO_BUCKET"]
|
||||
image_id = job_id_list[0]
|
||||
object_name = ImageRepository._object_name(image_id)
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, ImageRepository.image_format)
|
||||
num_bytes = buffer.getbuffer().nbytes
|
||||
buffer.seek(0)
|
||||
# Upload image to MinIO
|
||||
raw_minio_client.put_object(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
data=buffer,
|
||||
length=num_bytes,
|
||||
part_size=MinioAdapter.chunk_size,
|
||||
)
|
||||
buffer.close()
|
||||
|
||||
yield image_id, image
|
||||
|
||||
# Cleanup: remove image from MinIO
|
||||
try:
|
||||
raw_minio_client.remove_object(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
)
|
||||
except S3Error as e:
|
||||
raise RuntimeError(f"Failed to remove object from bucket: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def annotation_repository() -> Generator[AnnotationRepository]:
|
||||
"""Provide an AnnotationRepository instance for testing."""
|
||||
# Instantiate and connect repository
|
||||
repo = AnnotationRepository()
|
||||
repo.connect()
|
||||
|
||||
yield repo
|
||||
|
||||
# Cleanup
|
||||
repo.disconnect()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def annotation() -> Generator[Annotation]:
|
||||
"""Provide a sample annotation for testing."""
|
||||
annotation = Annotation(
|
||||
angle=AngleEnum.HIGH,
|
||||
angle_uncertainty=True,
|
||||
colour=ColourEnum.HIGH,
|
||||
colour_uncertainty=False,
|
||||
contact=ContactEnum.OFFER,
|
||||
contact_uncertainty=True,
|
||||
depth=DepthEnum.LOW,
|
||||
depth_uncertainty=False,
|
||||
distance=DistanceEnum.CLOSE,
|
||||
distance_uncertainty=True,
|
||||
lighting=LightingEnum.MEDIUM,
|
||||
lighting_uncertainty=False,
|
||||
point_of_view=PointOfViewEnum.FRONTAL,
|
||||
point_of_view_uncertainty=True,
|
||||
)
|
||||
yield annotation
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def annotation_in_minio(
|
||||
set_environment_variables: dict[str, str],
|
||||
raw_minio_client: Minio,
|
||||
annotation: Annotation,
|
||||
job_id_list: list[UUID],
|
||||
) -> Generator[tuple[UUID, Annotation]]:
|
||||
"""Upload a sample annotation to MinIO and provide its ID for testing."""
|
||||
# Prepare object name and buffer
|
||||
bucket_name = set_environment_variables["MINIO_BUCKET"]
|
||||
job_id = job_id_list[0]
|
||||
object_name = AnnotationRepository._object_name(job_id)
|
||||
data_json = annotation.model_dump(mode="json")
|
||||
data_bytes = json.dumps(data_json).encode(AnnotationRepository.encoding)
|
||||
buffer = BytesIO(data_bytes)
|
||||
num_bytes = buffer.getbuffer().nbytes
|
||||
buffer.seek(0)
|
||||
# Upload annotation to MinIO
|
||||
raw_minio_client.put_object(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
data=buffer,
|
||||
length=num_bytes,
|
||||
part_size=MinioAdapter.chunk_size,
|
||||
)
|
||||
buffer.close()
|
||||
|
||||
yield job_id, annotation
|
||||
|
||||
# Cleanup: remove annotation from MinIO
|
||||
try:
|
||||
raw_minio_client.remove_object(
|
||||
bucket_name=bucket_name,
|
||||
object_name=object_name,
|
||||
)
|
||||
except S3Error as e:
|
||||
raise RuntimeError(f"Failed to remove object from bucket: {e}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def job_repository() -> Generator[JobRepository]:
|
||||
"""Provide a JobRepository instance for testing."""
|
||||
# Instantiate and connect repository
|
||||
repo = JobRepository()
|
||||
repo.connect()
|
||||
|
||||
yield repo
|
||||
|
||||
# Cleanup
|
||||
repo.disconnect()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def job(
|
||||
job_id_list: list[UUID],
|
||||
image: Image.Image,
|
||||
annotation: Annotation,
|
||||
) -> Generator[Job]:
|
||||
"""Provide a sample job for testing."""
|
||||
job = Job(
|
||||
job_id=job_id_list[0],
|
||||
image=image,
|
||||
annotation=annotation,
|
||||
)
|
||||
yield job
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def job_in_minio(
|
||||
job: Job,
|
||||
image_in_minio: tuple[UUID, Image.Image],
|
||||
annotation_in_minio: tuple[UUID, Annotation],
|
||||
) -> Generator[Job]:
|
||||
"""Provide a sample job with its components already in MinIO for testing."""
|
||||
# A job consists of an image and an annotation and both must be in MinIO
|
||||
# before the job can be considered to be in MinIO.
|
||||
# Ensure the job_id matches those of the image and annotation
|
||||
image_id, _ = image_in_minio
|
||||
annotation_id, _ = annotation_in_minio
|
||||
assert job.job_id == annotation_id
|
||||
assert job.job_id == image_id
|
||||
yield job
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Integration tests for ImageRepositoryInterface."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from data_store.interfaces import ImageRepositoryInterface
|
||||
|
||||
|
||||
def test_get_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling get on a subclass without get implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(ImageRepositoryInterface):
|
||||
"""A class missing the get method."""
|
||||
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
return super().get(image_id) # type: ignore
|
||||
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.get(job_id_list[0])
|
||||
|
||||
|
||||
def test_put_raises_not_implemented_if_not_overwritten(
|
||||
image: Image.Image,
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling put on a subclass without put implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(ImageRepositoryInterface):
|
||||
"""A class missing the put method."""
|
||||
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
return None
|
||||
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
return super().put(image, image_id) # type: ignore
|
||||
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.put(image, job_id_list[0])
|
||||
|
||||
|
||||
def test_delete_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling delete on a subclass without delete implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(ImageRepositoryInterface):
|
||||
"""A class missing the delete method."""
|
||||
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
return None
|
||||
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
return super().delete(image_id) # type: ignore
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.delete(job_id_list[0])
|
||||
|
||||
|
||||
def test_list_all_raises_not_implemented_if_not_overwritten() -> None:
|
||||
"""Test that calling list_all on a subclass without list_all implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(ImageRepositoryInterface):
|
||||
"""A class missing the list_all method."""
|
||||
|
||||
def get(self, image_id: UUID) -> Image.Image | None:
|
||||
return None
|
||||
|
||||
def put(self, image: Image.Image, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, image_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return super().list_all() # type: ignore
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.list_all()
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Integration tests for ImageRepository."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from data_store.repositories.image_repository import ImageRepository
|
||||
|
||||
|
||||
def same_image(img1: Image.Image, img2: Image.Image) -> bool:
|
||||
"""Check if two images are the same based on their attributes."""
|
||||
return (
|
||||
img1.size == img2.size
|
||||
and img1.mode == img2.mode
|
||||
and list(img1.getdata()) == list(img2.getdata())
|
||||
)
|
||||
|
||||
|
||||
def test_get_image(
|
||||
image_in_minio: tuple[UUID, Image.Image],
|
||||
image_repository: ImageRepository,
|
||||
) -> None:
|
||||
"""Test retrieving an image from ImageRepository."""
|
||||
# Arrange
|
||||
image_id, expected_image = image_in_minio
|
||||
# Act
|
||||
retrieved_image = image_repository.get(image_id)
|
||||
# Assert
|
||||
assert retrieved_image is not None
|
||||
assert isinstance(retrieved_image, Image.Image)
|
||||
assert same_image(retrieved_image, expected_image)
|
||||
|
||||
|
||||
def test_put_image(
|
||||
image_repository: ImageRepository,
|
||||
image: Image.Image,
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test storing an image in ImageRepository."""
|
||||
# Arrange
|
||||
image_id = job_id_list[0]
|
||||
assert image_repository.get(image_id) is None
|
||||
# Act
|
||||
image_repository.put(image, image_id)
|
||||
# Assert
|
||||
received_image = image_repository.get(image_id)
|
||||
assert received_image is not None
|
||||
assert isinstance(received_image, Image.Image)
|
||||
assert received_image.size == image.size
|
||||
assert received_image.mode == image.mode
|
||||
assert list(received_image.getdata()) == list(image.getdata())
|
||||
|
||||
|
||||
def test_delete_image(
|
||||
image_in_minio: tuple[UUID, Image.Image],
|
||||
image_repository: ImageRepository,
|
||||
) -> None:
|
||||
"""Test deleting an image from ImageRepository."""
|
||||
# Arrange
|
||||
image_id, _ = image_in_minio
|
||||
assert image_repository.get(image_id) is not None
|
||||
# Act
|
||||
image_repository.delete(image_id)
|
||||
# Assert
|
||||
assert image_repository.get(image_id) is None
|
||||
|
||||
|
||||
def test_list_all_images(
|
||||
image_in_minio: tuple[UUID, Image.Image],
|
||||
image_repository: ImageRepository,
|
||||
) -> None:
|
||||
"""Test listing all images in ImageRepository."""
|
||||
# Arrange
|
||||
image_id, _ = image_in_minio
|
||||
# Act
|
||||
image_ids = image_repository.list_all()
|
||||
# Assert
|
||||
assert isinstance(image_ids, list)
|
||||
assert all(isinstance(i, UUID) for i in image_ids)
|
||||
assert image_id in image_ids
|
||||
|
||||
|
||||
def test_list_all_images_empty(
|
||||
image_repository: ImageRepository,
|
||||
) -> None:
|
||||
"""Test listing all images in an empty ImageRepository."""
|
||||
# Act
|
||||
image_ids = image_repository.list_all()
|
||||
# Assert
|
||||
assert isinstance(image_ids, list)
|
||||
assert len(image_ids) == 0
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Integration tests for the JobRepositoryInterface."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from data_store.interfaces import JobRepositoryInterface
|
||||
from data_store.dto import Job
|
||||
|
||||
|
||||
def test_get_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling get on a subclass without get implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(JobRepositoryInterface):
|
||||
"""A class missing the get method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
return super().get(job_id) # type: ignore
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.get(job_id_list[0])
|
||||
|
||||
|
||||
def test_put_raises_not_implemented_if_not_overwritten(
|
||||
job: Job,
|
||||
) -> None:
|
||||
"""Test that calling put on a subclass without put implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(JobRepositoryInterface):
|
||||
"""A class missing the put method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
return None
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
return super().put(job) # type: ignore
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.put(job)
|
||||
|
||||
|
||||
def test_delete_raises_not_implemented_if_not_overwritten(
|
||||
job_id_list: list[UUID],
|
||||
) -> None:
|
||||
"""Test that calling delete on a subclass without delete implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(JobRepositoryInterface):
|
||||
"""A class missing the delete method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
return None
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return super().delete(job_id) # type: ignore
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return []
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.delete(job_id_list[0])
|
||||
|
||||
|
||||
def test_list_all_raises_not_implemented_if_not_overwritten() -> None:
|
||||
"""Test that calling list_all on a subclass without list_all implemented raises NotImplementedError."""
|
||||
|
||||
class Incomplete(JobRepositoryInterface):
|
||||
"""A class missing the list_all method."""
|
||||
|
||||
def get(self, job_id: UUID) -> Job | None:
|
||||
return None
|
||||
|
||||
def put(self, job: Job) -> None:
|
||||
return None
|
||||
|
||||
def delete(self, job_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
def list_all(self) -> list[UUID]:
|
||||
return super().list_all() # type: ignore
|
||||
|
||||
instance = Incomplete()
|
||||
with pytest.raises(NotImplementedError):
|
||||
instance.list_all()
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Integration tests for JobRepository."""
|
||||
|
||||
from uuid import UUID
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from data_store.repositories.job_repository import JobRepository
|
||||
from data_store.dto import Job
|
||||
|
||||
|
||||
def same_image(img1: Image.Image, img2: Image.Image) -> bool:
|
||||
"""Check if two images are the same based on their attributes."""
|
||||
assert img1.size == img2.size
|
||||
assert img1.mode == img2.mode
|
||||
assert list(img1.getdata()) == list(img2.getdata())
|
||||
return True
|
||||
|
||||
|
||||
def same_job(job1: Job, job2: Job) -> bool:
|
||||
"""Check if two Job instances are the same based on their attributes."""
|
||||
assert job1.job_id == job2.job_id
|
||||
assert same_image(job1.image, job2.image)
|
||||
assert job1.annotation == job2.annotation
|
||||
return True
|
||||
|
||||
|
||||
def test_get_job(
|
||||
job_in_minio: Job,
|
||||
job_repository: JobRepository,
|
||||
) -> None:
|
||||
"""Test retrieving a job from JobRepository."""
|
||||
# Arrange
|
||||
job_id = job_in_minio.job_id
|
||||
# Act
|
||||
retrieved_job = job_repository.get(job_id)
|
||||
# Assert
|
||||
assert retrieved_job is not None
|
||||
assert isinstance(retrieved_job, Job)
|
||||
assert same_job(retrieved_job, job_in_minio)
|
||||
|
||||
|
||||
def test_get_job_without_image_returns_none(
|
||||
annotation_in_minio: tuple[UUID, object],
|
||||
job_repository: JobRepository,
|
||||
) -> None:
|
||||
"""Test retrieving a job without an image returns None."""
|
||||
# Arrange
|
||||
job_id, _ = annotation_in_minio
|
||||
# Act
|
||||
retrieved_job = job_repository.get(job_id)
|
||||
# Assert
|
||||
assert retrieved_job is None
|
||||
|
||||
|
||||
def test_get_job_without_annotation_returns_none(
|
||||
image_in_minio: tuple[UUID, object],
|
||||
job_repository: JobRepository,
|
||||
) -> None:
|
||||
"""Test retrieving a job without an annotation returns None."""
|
||||
# Arrange
|
||||
job_id, _ = image_in_minio
|
||||
# Act
|
||||
retrieved_job = job_repository.get(job_id)
|
||||
# Assert
|
||||
assert retrieved_job is None
|
||||
|
||||
|
||||
def test_put_job(
|
||||
job_repository: JobRepository,
|
||||
job: Job,
|
||||
) -> None:
|
||||
"""Test storing a job in JobRepository."""
|
||||
# Arrange
|
||||
job_id = job.job_id
|
||||
assert job_repository.get(job_id) is None
|
||||
# Act
|
||||
job_repository.put(job)
|
||||
# Assert
|
||||
received_job = job_repository.get(job_id)
|
||||
assert received_job is not None
|
||||
assert isinstance(received_job, Job)
|
||||
assert same_job(received_job, job)
|
||||
|
||||
|
||||
def test_delete_job(
|
||||
job_in_minio: Job,
|
||||
job_repository: JobRepository,
|
||||
) -> None:
|
||||
"""Test deleting a job from JobRepository."""
|
||||
# Arrange
|
||||
job_id = job_in_minio.job_id
|
||||
assert job_repository.get(job_id) is not None
|
||||
# Act
|
||||
job_repository.delete(job_id)
|
||||
# Assert
|
||||
assert job_repository.get(job_id) is None
|
||||
|
||||
|
||||
def test_list_all_jobs(
|
||||
job_in_minio: Job,
|
||||
job_repository: JobRepository,
|
||||
) -> None:
|
||||
"""Test listing all job IDs from JobRepository."""
|
||||
# Arrange
|
||||
job_id = job_in_minio.job_id
|
||||
# Act
|
||||
job_ids = job_repository.list_all()
|
||||
# Assert
|
||||
assert isinstance(job_ids, list)
|
||||
assert all(isinstance(jid, UUID) for jid in job_ids)
|
||||
assert job_id in job_ids
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-s", "-v", __file__])
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Unit tests for AnnotationRepository class."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, _patch_dict
|
||||
from uuid import UUID
|
||||
|
||||
from data_store.repositories.annotation_repository import AnnotationRepository
|
||||
from data_store.dto import (
|
||||
Annotation,
|
||||
AngleEnum,
|
||||
ColourEnum,
|
||||
ContactEnum,
|
||||
DepthEnum,
|
||||
DistanceEnum,
|
||||
LightingEnum,
|
||||
PointOfViewEnum,
|
||||
)
|
||||
|
||||
|
||||
class TestAnnotationRepository(unittest.TestCase):
|
||||
"""Unit tests for AnnotationRepository class."""
|
||||
|
||||
patcher: _patch_dict
|
||||
repo: AnnotationRepository
|
||||
job_id: UUID
|
||||
bad_job_id: str
|
||||
annotation: Annotation
|
||||
bad_annotation: dict
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""Set up test case with a mocked MinioAdapter."""
|
||||
# Patch environment variables for MinioAdapter
|
||||
cls.patcher = patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"MINIO_ENDPOINT": "localhost:9000",
|
||||
"MINIO_ACCESS_KEY": "minioadmin",
|
||||
"MINIO_SECRET_KEY": "minioadmin",
|
||||
"MINIO_BUCKET": "test-bucket",
|
||||
},
|
||||
)
|
||||
cls.patcher.start()
|
||||
# Prepare AnnotationRepository
|
||||
cls.repo = AnnotationRepository()
|
||||
cls.repo._get = MagicMock() # type: ignore[method-assign]
|
||||
cls.repo._put = MagicMock() # type: ignore[method-assign]
|
||||
cls.repo._delete = MagicMock() # type: ignore[method-assign]
|
||||
# Prepare other test variables
|
||||
cls.job_id = UUID("d87022f8-4169-4d69-8512-bbd65cc1cb23")
|
||||
cls.bad_job_id = "not-a-uuid"
|
||||
cls.annotation = Annotation(
|
||||
angle=AngleEnum.HIGH,
|
||||
angle_uncertainty=True,
|
||||
colour=ColourEnum.HIGH,
|
||||
colour_uncertainty=False,
|
||||
contact=ContactEnum.OFFER,
|
||||
contact_uncertainty=True,
|
||||
depth=DepthEnum.LOW,
|
||||
depth_uncertainty=False,
|
||||
distance=DistanceEnum.CLOSE,
|
||||
distance_uncertainty=True,
|
||||
lighting=LightingEnum.MEDIUM,
|
||||
lighting_uncertainty=False,
|
||||
point_of_view=PointOfViewEnum.FRONTAL,
|
||||
point_of_view_uncertainty=True,
|
||||
)
|
||||
cls.bad_annotation = {"not_an_annotation": "irrelevant value"}
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
# Stop patching
|
||||
cls.patcher.stop()
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up each test with a fresh AnnotationRepository instance."""
|
||||
self.repo._get.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._put.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._delete.reset_mock() # type: ignore[attr-defined]
|
||||
|
||||
def test_should_adhere_to_interface(self) -> None:
|
||||
"""Test that AnnotationRepository adheres to AnnotationRepositoryInterface."""
|
||||
# Instantiation fails if interface not adhered to
|
||||
_ = AnnotationRepository()
|
||||
|
||||
def test_should_have_encoding_set(self) -> None:
|
||||
"""Test that AnnotationRepository has encoding when instantiated."""
|
||||
self.assertTrue(hasattr(self.repo, "encoding"))
|
||||
|
||||
def test_should_have_data_format_set(self) -> None:
|
||||
"""Test that AnnotationRepository has data_format when instantiated."""
|
||||
self.assertTrue(hasattr(self.repo, "data_format"))
|
||||
|
||||
def test_should_have_folder_name_set(self) -> None:
|
||||
"""Test that AnnotationRepository has folder_name when instantiated."""
|
||||
self.assertTrue(hasattr(self.repo, "folder_name"))
|
||||
|
||||
def test_object_name_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that _object_name() raises ValueError for invalid job_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
_ = self.repo._object_name(self.bad_job_id) # type: ignore
|
||||
|
||||
def test_get_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that get() raises ValueError for invalid job_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.get(self.bad_job_id) # type: ignore
|
||||
|
||||
def test_get_data_that_cannot_be_converted_returns_none(self) -> None:
|
||||
"""Test that get() returns None if retrieved data cannot be converted to Annotation."""
|
||||
# Arrange
|
||||
bad_data = b'{"not":"valid annotation data"}'
|
||||
mock_buffer = MagicMock()
|
||||
mock_buffer.read.return_value = bad_data
|
||||
self.repo._get.return_value = mock_buffer # type: ignore[attr-defined]
|
||||
# Act
|
||||
result = self.repo.get(self.job_id)
|
||||
# Assert
|
||||
self.assertIsNone(result)
|
||||
self.repo._get.assert_called_once_with( # type: ignore[attr-defined]
|
||||
f"{self.repo.folder_name}/{self.job_id}.{self.repo.data_format}"
|
||||
)
|
||||
|
||||
def test_put_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that put() raises ValueError for invalid job_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.put(self.annotation, self.bad_job_id) # type: ignore
|
||||
|
||||
def test_put_invalid_annotation_raises_value_error(self) -> None:
|
||||
"""Test that put() raises ValueError for invalid annotation."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.put(self.bad_annotation, self.job_id) # type: ignore
|
||||
|
||||
def test_delete_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that delete() raises ValueError for invalid job_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.delete(self.bad_job_id) # type: ignore
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for ImageRepository class."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, _patch_dict
|
||||
from uuid import UUID
|
||||
from PIL import Image
|
||||
|
||||
from data_store.repositories.image_repository import ImageRepository
|
||||
|
||||
|
||||
class TestImageRepository(unittest.TestCase):
|
||||
"""Unit tests for ImageRepository class."""
|
||||
|
||||
patcher: _patch_dict
|
||||
repo: ImageRepository
|
||||
image: Image.Image
|
||||
bad_image: str
|
||||
image_id: UUID
|
||||
bad_image_id: str
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""Set up test case with a mocked MinioAdapter."""
|
||||
# Patch environment variables for MinioAdapter
|
||||
cls.patcher = patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"MINIO_ENDPOINT": "localhost:9000",
|
||||
"MINIO_ACCESS_KEY": "minioadmin",
|
||||
"MINIO_SECRET_KEY": "minioadmin",
|
||||
"MINIO_BUCKET": "test-bucket",
|
||||
},
|
||||
)
|
||||
cls.patcher.start()
|
||||
# Prepare ImageRepository with mocked MinioAdapter
|
||||
cls.repo = ImageRepository()
|
||||
cls.repo._get = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
cls.repo._put = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
cls.repo._delete = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
# Prepare other test variables
|
||||
cls.image = Image.new("RGB", (10, 10))
|
||||
cls.image_id = UUID("d87022f8-4169-4d69-8512-bbd65cc1cb23")
|
||||
cls.bad_image = "not_an_image"
|
||||
cls.bad_image_id = "not-a-uuid"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
# Stop patching
|
||||
cls.patcher.stop()
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up each test with a fresh ImageRepository instance."""
|
||||
self.repo._get.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._put.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._delete.reset_mock() # type: ignore[attr-defined]
|
||||
|
||||
def test_should_adhere_to_interface(self) -> None:
|
||||
"""Test that ImageRepository adheres to ImageRepositoryInterface."""
|
||||
# Instantiation fails if interface not adhered to
|
||||
_ = ImageRepository()
|
||||
|
||||
def test_should_have_image_format_set(self) -> None:
|
||||
"""Test that ImageRepository has image_format set."""
|
||||
self.assertTrue(hasattr(self.repo, "image_format"))
|
||||
|
||||
def test_should_have_folder_name_set(self) -> None:
|
||||
"""Test that ImageRepository has folder_name set."""
|
||||
self.assertTrue(hasattr(self.repo, "folder_name"))
|
||||
|
||||
def test_object_name_invalid_image_id_raises_value_error(self) -> None:
|
||||
"""Test that _object_name() raises ValueError for invalid image_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
_ = self.repo._object_name(self.bad_image_id) # type: ignore
|
||||
|
||||
def test_get_invalid_image_id_raises_value_error(self) -> None:
|
||||
"""Test getting an image with invalid image_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.get(self.bad_image_id) # type: ignore
|
||||
|
||||
def test_put_invalid_image_id_raises_value_error(self) -> None:
|
||||
"""Test putting an image with invalid image_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.put(self.image, self.bad_image_id) # type: ignore
|
||||
|
||||
def test_put_invalid_image_type_raises_value_error(self) -> None:
|
||||
"""Test putting an invalid image type."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.put(self.bad_image, self.bad_image_id) # type: ignore
|
||||
|
||||
def test_delete_invalid_image_id_raises_value_error(self) -> None:
|
||||
"""Test deleting an image with invalid image_id."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.delete(self.bad_image_id) # type: ignore
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Unit tests for JobRepository class."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, _patch_dict
|
||||
from uuid import UUID
|
||||
|
||||
from data_store.repositories.job_repository import JobRepository
|
||||
|
||||
|
||||
class TestJobRepository(unittest.TestCase):
|
||||
"""Unit tests for JobRepository class."""
|
||||
|
||||
patcher: _patch_dict
|
||||
repo: JobRepository
|
||||
job_id: UUID
|
||||
bad_job_id: str
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""Set up test case with a mocked MinioAdapter."""
|
||||
# Patch environment variables for MinioAdapter
|
||||
cls.patcher = patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"MINIO_ENDPOINT": "localhost:9000",
|
||||
"MINIO_ACCESS_KEY": "minioadmin",
|
||||
"MINIO_SECRET_KEY": "minioadmin",
|
||||
"MINIO_BUCKET": "test-bucket",
|
||||
},
|
||||
)
|
||||
cls.patcher.start()
|
||||
# Prepare JobRepository
|
||||
cls.repo = JobRepository()
|
||||
cls.repo._get = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
cls.repo._put = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
cls.repo._delete = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
# Prepare other test variables
|
||||
cls.job_id = UUID("d87022f8-4169-4d69-8512-bbd65cc1cb23")
|
||||
cls.bad_job_id = "not-a-uuid"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
# Stop patching
|
||||
cls.patcher.stop()
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up each test with a fresh JobRepository instance."""
|
||||
self.repo._get.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._put.reset_mock() # type: ignore[attr-defined]
|
||||
self.repo._delete.reset_mock() # type: ignore[attr-defined]
|
||||
|
||||
def test_should_adhere_to_interface(self) -> None:
|
||||
"""Test that JobRepository adheres to JobRepositoryInterface."""
|
||||
# Instantiation fails if interface not adhered to
|
||||
_ = JobRepository()
|
||||
|
||||
def test_enter_calls_super_and_returns_self(self) -> None:
|
||||
"""Test that __enter__ calls super().__enter__() and returns self."""
|
||||
repo = JobRepository()
|
||||
with patch(
|
||||
"python_repositories.adapters.MinioAdapter.__enter__", return_value=None
|
||||
) as mock_super_enter:
|
||||
result = repo.__enter__()
|
||||
mock_super_enter.assert_called_once()
|
||||
self.assertIs(result, repo)
|
||||
|
||||
def test_get_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that get with invalid job_id raises ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.get(self.bad_job_id) # type: ignore[arg-type]
|
||||
|
||||
def test_put_invalid_job_raises_value_error(self) -> None:
|
||||
"""Test that put with invalid job raises ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.put("not_a_job") # type: ignore[arg-type]
|
||||
|
||||
def test_delete_invalid_job_id_raises_value_error(self) -> None:
|
||||
"""Test that delete with invalid job_id raises ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.repo.delete(self.bad_job_id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Allows local debugging by running file as script
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user