truthscan-image-detector-client 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthscan_image_detector_client-0.0.1/PKG-INFO +7 -0
- truthscan_image_detector_client-0.0.1/pyproject.toml +17 -0
- truthscan_image_detector_client-0.0.1/setup.cfg +4 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/__init__.py +40 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/api_types.py +52 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/client.py +128 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/errors.py +28 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/logger.py +79 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/py.typed +0 -0
- truthscan_image_detector_client-0.0.1/src/ai_image_detection/service.py +496 -0
- truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/PKG-INFO +7 -0
- truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/SOURCES.txt +14 -0
- truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/dependency_links.txt +1 -0
- truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/requires.txt +1 -0
- truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/top_level.txt +1 -0
- truthscan_image_detector_client-0.0.1/tests/test_client.py +304 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "truthscan-image-detector-client"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Python client for AI Image Detection API"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
authors = [{name = "undetectableai"}]
|
|
11
|
+
dependencies = ["requests>=2.31.0"]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
where = ["src"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.package-data]
|
|
17
|
+
ai_image_detection = ["py.typed"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .client import ImageDetectionClient
|
|
2
|
+
from .service import ImageDetectionService
|
|
3
|
+
from .logger import Logger, DefaultConsoleLogger
|
|
4
|
+
from .errors import (
|
|
5
|
+
ApiClientError,
|
|
6
|
+
PresignError,
|
|
7
|
+
UploadError,
|
|
8
|
+
DetectError,
|
|
9
|
+
QueryError,
|
|
10
|
+
CreditCheckError,
|
|
11
|
+
)
|
|
12
|
+
from .api_types import (
|
|
13
|
+
PresignResponse,
|
|
14
|
+
DetectResponse,
|
|
15
|
+
ResultDetails,
|
|
16
|
+
QueryResponse,
|
|
17
|
+
CreditCheckResponse,
|
|
18
|
+
Image,
|
|
19
|
+
DetectionResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ImageDetectionClient",
|
|
24
|
+
"ImageDetectionService",
|
|
25
|
+
"Logger",
|
|
26
|
+
"DefaultConsoleLogger",
|
|
27
|
+
"ApiClientError",
|
|
28
|
+
"PresignError",
|
|
29
|
+
"UploadError",
|
|
30
|
+
"DetectError",
|
|
31
|
+
"QueryError",
|
|
32
|
+
"CreditCheckError",
|
|
33
|
+
"PresignResponse",
|
|
34
|
+
"DetectResponse",
|
|
35
|
+
"ResultDetails",
|
|
36
|
+
"QueryResponse",
|
|
37
|
+
"CreditCheckResponse",
|
|
38
|
+
"Image",
|
|
39
|
+
"DetectionResult",
|
|
40
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import TypedDict, Optional, Dict, Any, List, Tuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PresignResponse(TypedDict):
|
|
5
|
+
"""Response from presign endpoint"""
|
|
6
|
+
status: str
|
|
7
|
+
presigned_url: str
|
|
8
|
+
file_path: str
|
|
9
|
+
document_id: Optional[str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DetectResponse(TypedDict):
|
|
13
|
+
"""Response from detect endpoint"""
|
|
14
|
+
id: str
|
|
15
|
+
status: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResultDetails(TypedDict, total=False):
|
|
19
|
+
"""Detailed detection results"""
|
|
20
|
+
is_valid: Optional[bool]
|
|
21
|
+
detection_step: Optional[int]
|
|
22
|
+
final_result: Optional[str]
|
|
23
|
+
metadata: Optional[List[Tuple[str, str]]]
|
|
24
|
+
metadata_basic_source: Optional[str]
|
|
25
|
+
ocr: Optional[List[Tuple[str, int]]]
|
|
26
|
+
ml_model: Optional[List[Tuple[str, int]]]
|
|
27
|
+
confidence: Optional[int]
|
|
28
|
+
analysis_results: Optional[Dict[str, Any]]
|
|
29
|
+
analysis_results_status: Optional[str]
|
|
30
|
+
heatmap_url: Optional[str]
|
|
31
|
+
heatmap_status: Optional[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QueryResponse(TypedDict, total=False):
|
|
35
|
+
"""Response from query endpoint"""
|
|
36
|
+
id: str
|
|
37
|
+
status: str
|
|
38
|
+
result: Optional[int]
|
|
39
|
+
result_details: Optional[ResultDetails]
|
|
40
|
+
preview_url: Optional[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CreditCheckResponse(TypedDict):
|
|
44
|
+
"""Response from credit check endpoint"""
|
|
45
|
+
baseCredits: int
|
|
46
|
+
boostCredits: int
|
|
47
|
+
credits: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Type aliases
|
|
51
|
+
Image = str
|
|
52
|
+
DetectionResult = QueryResponse
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from .service import ImageDetectionService
|
|
5
|
+
from .logger import Logger, DefaultConsoleLogger
|
|
6
|
+
from .errors import QueryError, CreditCheckError
|
|
7
|
+
from .api_types import Image, DetectionResult, CreditCheckResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImageDetectionClient:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
api_key: str,
|
|
14
|
+
base_url: Optional[str] = None,
|
|
15
|
+
timeout: Optional[int] = None,
|
|
16
|
+
logger: Optional[Logger] = None
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Initialize the Image Detection Client.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
api_key: API key for authentication
|
|
23
|
+
base_url: Base URL of the API server (optional, default: 'https://ai-image-detect.undetectable.ai')
|
|
24
|
+
timeout: Default timeout for requests in seconds (optional, default: 60)
|
|
25
|
+
logger: Logger instance (optional, default: DefaultConsoleLogger("info"))
|
|
26
|
+
"""
|
|
27
|
+
self.base_url = base_url or 'https://ai-image-detect.undetectable.ai'
|
|
28
|
+
resolved_logger = logger if logger is not None else DefaultConsoleLogger("info")
|
|
29
|
+
self.service = ImageDetectionService(
|
|
30
|
+
self.base_url,
|
|
31
|
+
api_key,
|
|
32
|
+
timeout or 60,
|
|
33
|
+
resolved_logger
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def detect(
|
|
37
|
+
self,
|
|
38
|
+
image: Image,
|
|
39
|
+
email: Optional[str] = None,
|
|
40
|
+
generate_preview: bool = True,
|
|
41
|
+
max_poll_attempts: int = 60,
|
|
42
|
+
poll_interval_seconds: float = 0.5
|
|
43
|
+
) -> DetectionResult:
|
|
44
|
+
"""
|
|
45
|
+
Detect AI-generated content in an image.
|
|
46
|
+
|
|
47
|
+
This method handles the entire workflow:
|
|
48
|
+
1. Presign - Get presigned URL for upload
|
|
49
|
+
2. Upload - Upload the image file
|
|
50
|
+
3. Detect - Submit the image for detection
|
|
51
|
+
4. Poll - Wait for detection results
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
image: File path to the image to detect
|
|
55
|
+
email: Optional email address for processing
|
|
56
|
+
generate_preview: Optional flag to generate preview (default: True)
|
|
57
|
+
max_poll_attempts: Maximum number of polling attempts (default: 60)
|
|
58
|
+
poll_interval_seconds: Seconds to sleep between polling attempts (default: 0.5)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
DetectionResult containing detection status and results
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
QueryError: If polling times out or other errors occur
|
|
65
|
+
"""
|
|
66
|
+
# 1. Extract filename from path
|
|
67
|
+
file_name = os.path.basename(image).replace(' ', '')
|
|
68
|
+
|
|
69
|
+
# 2. Presign - Get presigned URL
|
|
70
|
+
presign_result = self.service.get_presigned_url(file_name)
|
|
71
|
+
|
|
72
|
+
# 3. Upload - Upload the file
|
|
73
|
+
self.service.upload(presign_result["presigned_url"], image)
|
|
74
|
+
|
|
75
|
+
# 4. Build file URL for detect operation
|
|
76
|
+
# Determine CDN base URL based on the baseUrl (dev vs prod)
|
|
77
|
+
cdn_base_url = self._get_cdn_base_url()
|
|
78
|
+
file_path_remote_full = f"{cdn_base_url}{presign_result['file_path']}"
|
|
79
|
+
file_url = ImageDetectionService.build_detect_file_url(
|
|
80
|
+
file_path_remote_full,
|
|
81
|
+
presign_result["presigned_url"]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 5. Detect - Submit for detection
|
|
85
|
+
detect_result = self.service.detect(
|
|
86
|
+
file_url,
|
|
87
|
+
email,
|
|
88
|
+
generate_preview
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# 6. Poll for result - Wait for completion
|
|
92
|
+
final_result = self.service.poll_for_result(
|
|
93
|
+
detect_result["id"],
|
|
94
|
+
max_poll_attempts,
|
|
95
|
+
poll_interval_seconds
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if final_result is None:
|
|
99
|
+
raise QueryError('Polling timeout - detection result not available')
|
|
100
|
+
|
|
101
|
+
return final_result
|
|
102
|
+
|
|
103
|
+
def check_user_credits(self) -> CreditCheckResponse:
|
|
104
|
+
"""
|
|
105
|
+
Check user credits for the API key.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
CreditCheckResponse containing credit information
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
CreditCheckError: If the credit check operation fails
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return self.service.check_user_credits()
|
|
115
|
+
except Exception as e:
|
|
116
|
+
raise CreditCheckError(str(e)) from e
|
|
117
|
+
|
|
118
|
+
def _get_cdn_base_url(self) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Get the CDN base URL based on the configured baseUrl.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
CDN base URL string
|
|
124
|
+
"""
|
|
125
|
+
if 'undetectable.ai' in self.base_url:
|
|
126
|
+
return 'https://ai-image-detector-prod.nyc3.digitaloceanspaces.com/'
|
|
127
|
+
else:
|
|
128
|
+
return 'https://ai-image-detector-dev.nyc3.digitaloceanspaces.com/'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class ApiClientError(Exception):
|
|
2
|
+
"""Base exception for API client errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PresignError(ApiClientError):
|
|
7
|
+
"""Exception raised when presign operation fails."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UploadError(ApiClientError):
|
|
12
|
+
"""Exception raised when upload operation fails."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DetectError(ApiClientError):
|
|
17
|
+
"""Exception raised when detect operation fails."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueryError(ApiClientError):
|
|
22
|
+
"""Exception raised when query operation fails."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CreditCheckError(ApiClientError):
|
|
27
|
+
"""Exception raised when credit check operation fails."""
|
|
28
|
+
pass
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Logger(Protocol):
|
|
6
|
+
"""Logger interface for the Image Detection Client"""
|
|
7
|
+
|
|
8
|
+
def info(self, message: str) -> None:
|
|
9
|
+
"""Log an info message"""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def debug(self, message: str) -> None:
|
|
13
|
+
"""Log a debug message"""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def warn(self, message: str) -> None:
|
|
17
|
+
"""Log a warning message"""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def error(self, message: str) -> None:
|
|
21
|
+
"""Log an error message"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DefaultConsoleLogger:
|
|
26
|
+
"""
|
|
27
|
+
Default console logger implementation
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, log_level: str = "info") -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialize the default console logger.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
log_level: Logging level ('debug', 'info', 'warn', 'error', default: 'info')
|
|
36
|
+
"""
|
|
37
|
+
self.log_level = log_level
|
|
38
|
+
|
|
39
|
+
def _timestamp(self) -> str:
|
|
40
|
+
"""Generate ISO timestamp"""
|
|
41
|
+
return datetime.now().isoformat()
|
|
42
|
+
|
|
43
|
+
def _should_log(self, level: str) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Check if a message at the given level should be logged.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
level: Log level to check
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if the message should be logged
|
|
52
|
+
"""
|
|
53
|
+
levels = {
|
|
54
|
+
'debug': 0,
|
|
55
|
+
'info': 1,
|
|
56
|
+
'warn': 2,
|
|
57
|
+
'error': 3,
|
|
58
|
+
}
|
|
59
|
+
return levels.get(level, 1) >= levels.get(self.log_level, 1)
|
|
60
|
+
|
|
61
|
+
def info(self, message: str) -> None:
|
|
62
|
+
"""Log an info message"""
|
|
63
|
+
if self._should_log('info'):
|
|
64
|
+
print(f"[{self._timestamp()}] INFO: {message}")
|
|
65
|
+
|
|
66
|
+
def debug(self, message: str) -> None:
|
|
67
|
+
"""Log a debug message"""
|
|
68
|
+
if self._should_log('debug'):
|
|
69
|
+
print(f"[{self._timestamp()}] DEBUG: {message}")
|
|
70
|
+
|
|
71
|
+
def warn(self, message: str) -> None:
|
|
72
|
+
"""Log a warning message"""
|
|
73
|
+
if self._should_log('warn'):
|
|
74
|
+
print(f"[{self._timestamp()}] WARN: {message}")
|
|
75
|
+
|
|
76
|
+
def error(self, message: str) -> None:
|
|
77
|
+
"""Log an error message"""
|
|
78
|
+
if self._should_log('error'):
|
|
79
|
+
print(f"[{self._timestamp()}] ERROR: {message}")
|
|
File without changes
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import requests
|
|
4
|
+
import mimetypes
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
from typing import Dict, Optional, Any, Callable
|
|
7
|
+
|
|
8
|
+
from .logger import Logger, DefaultConsoleLogger
|
|
9
|
+
from .errors import PresignError, UploadError, DetectError, QueryError, CreditCheckError
|
|
10
|
+
from .api_types import PresignResponse, DetectResponse, QueryResponse, CreditCheckResponse
|
|
11
|
+
|
|
12
|
+
class ImageDetectionService:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
base_url: str,
|
|
16
|
+
api_key: str,
|
|
17
|
+
timeout: int = 60,
|
|
18
|
+
logger: Optional[Logger] = None
|
|
19
|
+
) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Initialize the API client interface.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
base_url: Base URL of the API server
|
|
25
|
+
api_key: API key for authentication
|
|
26
|
+
timeout: Default timeout for requests in seconds (default: 60)
|
|
27
|
+
logger: Logger instance (optional, default: DefaultConsoleLogger("info"))
|
|
28
|
+
"""
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
self.timeout = timeout # Timeout in seconds for requests library
|
|
32
|
+
self.logger = logger if logger is not None else DefaultConsoleLogger("info")
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def guess_mime_type(file_name: str) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Guess MIME type from file name.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
file_name: Name of the file
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
MIME type string
|
|
44
|
+
"""
|
|
45
|
+
mime, _ = mimetypes.guess_type(file_name)
|
|
46
|
+
if mime:
|
|
47
|
+
return mime
|
|
48
|
+
|
|
49
|
+
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
|
|
50
|
+
mime_map = {
|
|
51
|
+
'jpg': 'image/jpeg',
|
|
52
|
+
'jpeg': 'image/jpeg',
|
|
53
|
+
'jfif': 'image/jpeg',
|
|
54
|
+
'png': 'image/png',
|
|
55
|
+
'webp': 'image/webp',
|
|
56
|
+
'heic': 'image/heic',
|
|
57
|
+
'heif': 'image/heif',
|
|
58
|
+
'avif': 'image/avif',
|
|
59
|
+
'bmp': 'image/bmp',
|
|
60
|
+
'tiff': 'image/tiff',
|
|
61
|
+
'tif': 'image/tiff',
|
|
62
|
+
'svg': 'image/svg+xml',
|
|
63
|
+
'gif': 'image/gif',
|
|
64
|
+
'pdf': 'application/pdf',
|
|
65
|
+
}
|
|
66
|
+
return mime_map.get(ext, "application/octet-stream")
|
|
67
|
+
|
|
68
|
+
def get_presigned_url(self, file_name: str) -> PresignResponse:
|
|
69
|
+
"""
|
|
70
|
+
Get a presigned URL for file upload.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
file_name: Name of the file to upload
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
PresignResponse containing presigned_url and file_path
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
PresignError: If the presign operation fails
|
|
80
|
+
"""
|
|
81
|
+
self.logger.info(f"Requesting presigned URL for file: {file_name}")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
url = f"{self.base_url}/get-presigned-url"
|
|
85
|
+
response = requests.get(
|
|
86
|
+
url,
|
|
87
|
+
headers={'apikey': self.api_key},
|
|
88
|
+
params={'file_name': file_name},
|
|
89
|
+
timeout=self.timeout,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.logger.debug(f"Presign response status: {response.status_code}")
|
|
93
|
+
|
|
94
|
+
if not response.ok:
|
|
95
|
+
error_msg = f"Presign failed with status {response.status_code}"
|
|
96
|
+
try:
|
|
97
|
+
error_data = response.json()
|
|
98
|
+
error_msg = error_data.get("error", error_msg)
|
|
99
|
+
except Exception:
|
|
100
|
+
error_msg = f"{error_msg}: {response.text}"
|
|
101
|
+
|
|
102
|
+
self.logger.error(error_msg)
|
|
103
|
+
raise PresignError(error_msg)
|
|
104
|
+
|
|
105
|
+
result = response.json()
|
|
106
|
+
|
|
107
|
+
if "presigned_url" not in result or "file_path" not in result:
|
|
108
|
+
error_msg = "Invalid presign response: missing required fields"
|
|
109
|
+
self.logger.error(error_msg)
|
|
110
|
+
raise PresignError(error_msg)
|
|
111
|
+
|
|
112
|
+
self.logger.info(f"Presigned URL obtained successfully for {file_name}")
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
except PresignError:
|
|
116
|
+
raise
|
|
117
|
+
except requests.exceptions.RequestException as e:
|
|
118
|
+
error_msg = f"Network error during presign: {str(e)}"
|
|
119
|
+
self.logger.error(error_msg)
|
|
120
|
+
raise PresignError(error_msg) from e
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error_msg = f"Unexpected error during presign: {str(e)}"
|
|
123
|
+
self.logger.error(error_msg)
|
|
124
|
+
raise PresignError(error_msg) from e
|
|
125
|
+
|
|
126
|
+
def upload(
|
|
127
|
+
self,
|
|
128
|
+
presigned_url: str,
|
|
129
|
+
file_path: str,
|
|
130
|
+
mime_type: Optional[str] = None
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""
|
|
133
|
+
Upload a file to the presigned URL.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
presigned_url: Presigned URL obtained from get_presigned_url() method
|
|
137
|
+
file_path: Local path to the file to upload
|
|
138
|
+
mime_type: MIME type of the file (auto-detected if not provided)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
True if upload was successful
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
UploadError: If the upload operation fails
|
|
145
|
+
"""
|
|
146
|
+
if not os.path.exists(file_path):
|
|
147
|
+
error_msg = f"File not found: {file_path}"
|
|
148
|
+
self.logger.error(error_msg)
|
|
149
|
+
raise UploadError(error_msg)
|
|
150
|
+
|
|
151
|
+
if mime_type is None:
|
|
152
|
+
mime_type = ImageDetectionService.guess_mime_type(file_path)
|
|
153
|
+
|
|
154
|
+
self.logger.info(f"Uploading file {file_path} (MIME type: {mime_type})")
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
file_stats = os.stat(file_path)
|
|
158
|
+
file_size = file_stats.st_size
|
|
159
|
+
|
|
160
|
+
with open(file_path, "rb") as f:
|
|
161
|
+
response = requests.put(
|
|
162
|
+
presigned_url,
|
|
163
|
+
headers={
|
|
164
|
+
'Content-Type': mime_type,
|
|
165
|
+
'x-amz-acl': 'private',
|
|
166
|
+
'Content-Length': str(file_size),
|
|
167
|
+
},
|
|
168
|
+
data=f,
|
|
169
|
+
timeout=300, # 5 minutes for file uploads
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self.logger.debug(f"Upload response status: {response.status_code}")
|
|
173
|
+
|
|
174
|
+
if response.status_code not in (200, 204):
|
|
175
|
+
error_msg = f"Upload failed with status {response.status_code}"
|
|
176
|
+
self.logger.error(error_msg)
|
|
177
|
+
raise UploadError(error_msg)
|
|
178
|
+
|
|
179
|
+
self.logger.info(f"File uploaded successfully: {file_path}")
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
except UploadError:
|
|
183
|
+
raise
|
|
184
|
+
except requests.exceptions.RequestException as e:
|
|
185
|
+
error_msg = f"Network error during upload: {str(e)}"
|
|
186
|
+
self.logger.error(error_msg)
|
|
187
|
+
raise UploadError(error_msg) from e
|
|
188
|
+
except Exception as e:
|
|
189
|
+
error_msg = f"Unexpected error during upload: {str(e)}"
|
|
190
|
+
self.logger.error(error_msg)
|
|
191
|
+
raise UploadError(error_msg) from e
|
|
192
|
+
|
|
193
|
+
def detect(
|
|
194
|
+
self,
|
|
195
|
+
file_url: str,
|
|
196
|
+
email: Optional[str] = None,
|
|
197
|
+
generate_preview: bool = False
|
|
198
|
+
) -> DetectResponse:
|
|
199
|
+
"""
|
|
200
|
+
Submit an image for AI detection.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_url: URL of the uploaded file
|
|
204
|
+
email: Optional email address for processing
|
|
205
|
+
generate_preview: Optional flag to generate preview (default: False)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
DetectResponse containing detection ID and status
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
DetectError: If the detect operation fails
|
|
212
|
+
"""
|
|
213
|
+
self.logger.info(f"Submitting detect request for URL: {file_url}")
|
|
214
|
+
|
|
215
|
+
payload: Dict[str, Any] = {
|
|
216
|
+
"key": self.api_key,
|
|
217
|
+
"url": file_url,
|
|
218
|
+
"document_type": "Image",
|
|
219
|
+
"model": "generic",
|
|
220
|
+
"generate_preview": generate_preview,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if email:
|
|
224
|
+
payload["email"] = email
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
response = requests.post(
|
|
228
|
+
f"{self.base_url}/detect",
|
|
229
|
+
headers={'Content-Type': 'application/json'},
|
|
230
|
+
json=payload,
|
|
231
|
+
timeout=120, # 2 minutes for detect requests
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
self.logger.debug(f"Detect response status: {response.status_code}")
|
|
235
|
+
|
|
236
|
+
if not response.ok:
|
|
237
|
+
error_msg = f"Detect failed with status {response.status_code}"
|
|
238
|
+
try:
|
|
239
|
+
error_data = response.json()
|
|
240
|
+
error_msg = error_data.get("error", error_msg)
|
|
241
|
+
except Exception:
|
|
242
|
+
error_msg = f"{error_msg}: {response.text}"
|
|
243
|
+
|
|
244
|
+
self.logger.error(error_msg)
|
|
245
|
+
raise DetectError(error_msg)
|
|
246
|
+
|
|
247
|
+
result = response.json()
|
|
248
|
+
|
|
249
|
+
if "id" not in result:
|
|
250
|
+
error_msg = "Invalid detect response: missing detection ID"
|
|
251
|
+
self.logger.error(error_msg)
|
|
252
|
+
raise DetectError(error_msg)
|
|
253
|
+
|
|
254
|
+
detect_id = result.get("id")
|
|
255
|
+
self.logger.info(f"Detect request submitted successfully. ID: {detect_id}")
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
except DetectError:
|
|
259
|
+
raise
|
|
260
|
+
except Exception as e:
|
|
261
|
+
if isinstance(e, DetectError):
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
error_msg = (
|
|
265
|
+
f"Network error during detect: {str(e)}"
|
|
266
|
+
if hasattr(e, 'message') and e.message
|
|
267
|
+
else f"Unexpected error during detect: {str(e)}"
|
|
268
|
+
)
|
|
269
|
+
self.logger.error(error_msg)
|
|
270
|
+
raise DetectError(error_msg) from e
|
|
271
|
+
|
|
272
|
+
def query(self, detect_id: str) -> QueryResponse:
|
|
273
|
+
"""
|
|
274
|
+
Query the detection status and results.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
detect_id: Detection ID returned from detect() method
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
QueryResponse containing detection status and results
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
QueryError: If the query operation fails
|
|
284
|
+
"""
|
|
285
|
+
self.logger.debug(f"Querying detection status for ID: {detect_id}")
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
response = requests.post(
|
|
289
|
+
f"{self.base_url}/query",
|
|
290
|
+
headers={'Content-Type': 'application/json'},
|
|
291
|
+
json={'id': detect_id},
|
|
292
|
+
timeout=self.timeout,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
self.logger.debug(f"Query response status: {response.status_code}")
|
|
296
|
+
|
|
297
|
+
if not response.ok:
|
|
298
|
+
error_msg = f"Query failed with status {response.status_code}"
|
|
299
|
+
try:
|
|
300
|
+
error_data = response.json()
|
|
301
|
+
error_msg = error_data.get("error", error_msg)
|
|
302
|
+
except Exception:
|
|
303
|
+
error_msg = f"{error_msg}: {response.text}"
|
|
304
|
+
|
|
305
|
+
self.logger.error(error_msg)
|
|
306
|
+
raise QueryError(error_msg)
|
|
307
|
+
|
|
308
|
+
response_text = response.text
|
|
309
|
+
if response_text.strip() == "null" or not response_text.strip():
|
|
310
|
+
self.logger.warn(f"Document not found for ID: {detect_id}")
|
|
311
|
+
return {"id": detect_id, "status": "not_found"}
|
|
312
|
+
|
|
313
|
+
result = response.json()
|
|
314
|
+
|
|
315
|
+
if result is None:
|
|
316
|
+
self.logger.warn(f"Document not found for ID: {detect_id}")
|
|
317
|
+
return {"id": detect_id, "status": "not_found"}
|
|
318
|
+
|
|
319
|
+
status = result.get("status", "unknown")
|
|
320
|
+
self.logger.debug(f"Query result status: {status}")
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
except QueryError:
|
|
324
|
+
raise
|
|
325
|
+
except requests.exceptions.RequestException as e:
|
|
326
|
+
error_msg = f"Network error during query: {str(e)}"
|
|
327
|
+
self.logger.error(error_msg)
|
|
328
|
+
raise QueryError(error_msg) from e
|
|
329
|
+
except Exception as e:
|
|
330
|
+
error_msg = f"Unexpected error during query: {str(e)}"
|
|
331
|
+
self.logger.error(error_msg)
|
|
332
|
+
raise QueryError(error_msg) from e
|
|
333
|
+
|
|
334
|
+
def poll_for_result(
|
|
335
|
+
self,
|
|
336
|
+
detect_id: str,
|
|
337
|
+
max_attempts: int = 60,
|
|
338
|
+
sleep_seconds: float = 0.5,
|
|
339
|
+
callback: Optional[Callable[[int, QueryResponse], None]] = None
|
|
340
|
+
) -> Optional[QueryResponse]:
|
|
341
|
+
"""
|
|
342
|
+
Poll for detection results until completion.
|
|
343
|
+
|
|
344
|
+
Continues polling until status is "done" or "failed"
|
|
345
|
+
or until max_attempts is reached.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
detect_id: Detection ID returned from detect() method
|
|
349
|
+
max_attempts: Maximum number of polling attempts (default: 60)
|
|
350
|
+
sleep_seconds: Seconds to sleep between attempts (default: 0.5)
|
|
351
|
+
callback: Optional callback function called on each attempt
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Final result if status reached completion, None if timeout
|
|
355
|
+
"""
|
|
356
|
+
self.logger.info(f"Starting to poll for detection ID: {detect_id}")
|
|
357
|
+
|
|
358
|
+
final_result: Optional[QueryResponse] = None
|
|
359
|
+
|
|
360
|
+
for attempt_idx in range(1, max_attempts + 1):
|
|
361
|
+
try:
|
|
362
|
+
result = self.query(detect_id)
|
|
363
|
+
|
|
364
|
+
if callback:
|
|
365
|
+
try:
|
|
366
|
+
callback(attempt_idx, result)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
self.logger.warn(f"Callback error: {e}")
|
|
369
|
+
|
|
370
|
+
status = result.get("status", "unknown")
|
|
371
|
+
|
|
372
|
+
if status == "done" or status == "failed":
|
|
373
|
+
final_result = result
|
|
374
|
+
self.logger.info(
|
|
375
|
+
f"Polling completed. Status: {status} "
|
|
376
|
+
f"(attempt {attempt_idx}/{max_attempts})"
|
|
377
|
+
)
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
if attempt_idx < max_attempts:
|
|
381
|
+
time.sleep(sleep_seconds)
|
|
382
|
+
|
|
383
|
+
except QueryError as e:
|
|
384
|
+
self.logger.warn(f"Query error on attempt {attempt_idx}: {e}")
|
|
385
|
+
time.sleep(sleep_seconds)
|
|
386
|
+
continue
|
|
387
|
+
except Exception as e:
|
|
388
|
+
self.logger.warn(
|
|
389
|
+
f"Unexpected error on attempt {attempt_idx}: "
|
|
390
|
+
f"{str(e) if hasattr(e, '__str__') else 'Unknown error'}"
|
|
391
|
+
)
|
|
392
|
+
time.sleep(sleep_seconds)
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
if final_result is None:
|
|
396
|
+
self.logger.warn(
|
|
397
|
+
f"Polling timeout reached for detection ID: {detect_id} "
|
|
398
|
+
f"(max attempts: {max_attempts})"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return final_result
|
|
402
|
+
|
|
403
|
+
def check_user_credits(self) -> CreditCheckResponse:
|
|
404
|
+
"""
|
|
405
|
+
Check user credits for the API key.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
CreditCheckResponse containing credit information
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
CreditCheckError: If the credit check operation fails
|
|
412
|
+
"""
|
|
413
|
+
self.logger.info(f"Requesting credit check for API key: {self.api_key}")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
response = requests.get(
|
|
417
|
+
f"{self.base_url}/check-user-credits",
|
|
418
|
+
headers={'apikey': self.api_key},
|
|
419
|
+
timeout=self.timeout,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
self.logger.debug(f"Credit check response status: {response.status_code}")
|
|
423
|
+
|
|
424
|
+
if not response.ok:
|
|
425
|
+
error_msg = f"Credit check failed with status {response.status_code}"
|
|
426
|
+
try:
|
|
427
|
+
error_data = response.json()
|
|
428
|
+
error_msg = error_data.get("error", error_msg)
|
|
429
|
+
except Exception:
|
|
430
|
+
error_msg = f"{error_msg}: {response.text}"
|
|
431
|
+
|
|
432
|
+
self.logger.error(error_msg)
|
|
433
|
+
raise CreditCheckError(error_msg)
|
|
434
|
+
|
|
435
|
+
result = response.json()
|
|
436
|
+
|
|
437
|
+
# Check if response has required fields
|
|
438
|
+
if not result or not isinstance(result, dict):
|
|
439
|
+
error_msg = (
|
|
440
|
+
f"Invalid credit check response: unexpected response format. "
|
|
441
|
+
f"Response: {result}"
|
|
442
|
+
)
|
|
443
|
+
self.logger.error(error_msg)
|
|
444
|
+
raise CreditCheckError(error_msg)
|
|
445
|
+
|
|
446
|
+
# Check for required fields (allow 0 values but not undefined/null)
|
|
447
|
+
if (
|
|
448
|
+
result.get("baseCredits") is None
|
|
449
|
+
or result.get("boostCredits") is None
|
|
450
|
+
or result.get("credits") is None
|
|
451
|
+
):
|
|
452
|
+
error_msg = (
|
|
453
|
+
f"Invalid credit check response: missing required fields. "
|
|
454
|
+
f"Response: {result}"
|
|
455
|
+
)
|
|
456
|
+
self.logger.error(error_msg)
|
|
457
|
+
raise CreditCheckError(error_msg)
|
|
458
|
+
|
|
459
|
+
self.logger.info("Credit check obtained successfully")
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
except CreditCheckError:
|
|
463
|
+
raise
|
|
464
|
+
except requests.exceptions.RequestException as e:
|
|
465
|
+
error_msg = f"Network error during credit check: {str(e)}"
|
|
466
|
+
self.logger.error(error_msg)
|
|
467
|
+
raise CreditCheckError(error_msg) from e
|
|
468
|
+
except Exception as e:
|
|
469
|
+
error_msg = f"Unexpected error during credit check: {str(e)}"
|
|
470
|
+
self.logger.error(error_msg)
|
|
471
|
+
raise CreditCheckError(error_msg) from e
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def build_detect_file_url(
|
|
475
|
+
file_path_remote: str,
|
|
476
|
+
presigned_url: str
|
|
477
|
+
) -> str:
|
|
478
|
+
"""
|
|
479
|
+
Build the full file URL for detect operation.
|
|
480
|
+
|
|
481
|
+
If file_path_remote is already a full URL, returns it as-is.
|
|
482
|
+
Otherwise, constructs URL from presigned URL origin.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
file_path_remote: Remote file path or full URL
|
|
486
|
+
presigned_url: Presigned URL to extract origin from
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Full URL to the file
|
|
490
|
+
"""
|
|
491
|
+
if file_path_remote.startswith("http://") or file_path_remote.startswith("https://"):
|
|
492
|
+
return file_path_remote
|
|
493
|
+
|
|
494
|
+
parsed = urlparse(presigned_url)
|
|
495
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
496
|
+
return f"{origin}/{file_path_remote.lstrip('/')}"
|
truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/ai_image_detection/__init__.py
|
|
3
|
+
src/ai_image_detection/api_types.py
|
|
4
|
+
src/ai_image_detection/client.py
|
|
5
|
+
src/ai_image_detection/errors.py
|
|
6
|
+
src/ai_image_detection/logger.py
|
|
7
|
+
src/ai_image_detection/py.typed
|
|
8
|
+
src/ai_image_detection/service.py
|
|
9
|
+
src/truthscan_image_detector_client.egg-info/PKG-INFO
|
|
10
|
+
src/truthscan_image_detector_client.egg-info/SOURCES.txt
|
|
11
|
+
src/truthscan_image_detector_client.egg-info/dependency_links.txt
|
|
12
|
+
src/truthscan_image_detector_client.egg-info/requires.txt
|
|
13
|
+
src/truthscan_image_detector_client.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/requires.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.31.0
|
truthscan_image_detector_client-0.0.1/src/truthscan_image_detector_client.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_image_detection
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Optional, Any
|
|
7
|
+
|
|
8
|
+
from ai_image_detection import ImageDetectionClient
|
|
9
|
+
|
|
10
|
+
def log(message: str) -> None:
|
|
11
|
+
"""Log a message with timestamp."""
|
|
12
|
+
timestamp = time.strftime('%H:%M:%S')
|
|
13
|
+
print(f"[{timestamp}] {message}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discover_image_files(
|
|
17
|
+
folder: str,
|
|
18
|
+
allowed_exts: set,
|
|
19
|
+
min_bytes: int,
|
|
20
|
+
max_bytes: int
|
|
21
|
+
) -> List[str]:
|
|
22
|
+
"""
|
|
23
|
+
Discover image files in the specified folder.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
folder: Folder path to search
|
|
27
|
+
allowed_exts: Set of allowed file extensions
|
|
28
|
+
min_bytes: Minimum file size in bytes
|
|
29
|
+
max_bytes: Maximum file size in bytes
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of image file names
|
|
33
|
+
"""
|
|
34
|
+
files: List[str] = []
|
|
35
|
+
|
|
36
|
+
if not os.path.exists(folder):
|
|
37
|
+
return files
|
|
38
|
+
|
|
39
|
+
entries = os.listdir(folder)
|
|
40
|
+
|
|
41
|
+
for entry in entries:
|
|
42
|
+
entry_path = os.path.join(folder, entry)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
stats = os.stat(entry_path)
|
|
46
|
+
if os.path.isdir(entry_path):
|
|
47
|
+
continue
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
ext = os.path.splitext(entry)[1].lower()
|
|
52
|
+
if ext not in allowed_exts:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
file_size = stats.st_size
|
|
57
|
+
if file_size < min_bytes or file_size > max_bytes:
|
|
58
|
+
continue
|
|
59
|
+
except Exception:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
files.append(entry)
|
|
63
|
+
|
|
64
|
+
return files
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def process_file(
|
|
68
|
+
client: ImageDetectionClient,
|
|
69
|
+
folder: str,
|
|
70
|
+
file_name: str
|
|
71
|
+
) -> Dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Process a single image file through the complete workflow.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
client: ImageDetectionClient instance
|
|
77
|
+
folder: Folder containing the image
|
|
78
|
+
file_name: Name of the image file
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Dictionary containing processing results and timing information
|
|
82
|
+
"""
|
|
83
|
+
log(f"Processing file: {file_name}")
|
|
84
|
+
|
|
85
|
+
file_start_time = time.time()
|
|
86
|
+
full_path = os.path.join(folder, file_name)
|
|
87
|
+
|
|
88
|
+
result_data: Dict[str, Any] = {
|
|
89
|
+
"filename": file_name,
|
|
90
|
+
"detect_id": None,
|
|
91
|
+
"status": "error",
|
|
92
|
+
"error_message": None,
|
|
93
|
+
"query_result": None,
|
|
94
|
+
"total_time": 0,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
log(f"Starting detection for {file_name}...")
|
|
99
|
+
final_result = client.detect(full_path)
|
|
100
|
+
|
|
101
|
+
result_data["total_time"] = time.time() - file_start_time
|
|
102
|
+
result_data["detect_id"] = final_result.get("id")
|
|
103
|
+
result_data["query_result"] = final_result
|
|
104
|
+
result_data["status"] = final_result.get("status", "unknown")
|
|
105
|
+
|
|
106
|
+
status = final_result.get("status")
|
|
107
|
+
result_value = final_result.get("result")
|
|
108
|
+
log(f"Detection completed. Status: {status}, Result: {result_value}")
|
|
109
|
+
log(f"Total time: {result_data['total_time']:.3f}s")
|
|
110
|
+
|
|
111
|
+
return result_data
|
|
112
|
+
except Exception as e:
|
|
113
|
+
result_data["total_time"] = time.time() - file_start_time
|
|
114
|
+
if e is not None:
|
|
115
|
+
result_data["error_message"] = f"Query error: {str(e)}"
|
|
116
|
+
result_data["status"] = "query_error"
|
|
117
|
+
else:
|
|
118
|
+
result_data["error_message"] = f"Error: {str(e)}"
|
|
119
|
+
result_data["status"] = "error"
|
|
120
|
+
log(f"Error: {result_data['error_message']}")
|
|
121
|
+
return result_data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def print_summary(results: List[Dict[str, Any]]) -> None:
|
|
125
|
+
"""Print a summary of all processing results."""
|
|
126
|
+
print("\n" + "=" * 70)
|
|
127
|
+
print(" PROCESSING SUMMARY")
|
|
128
|
+
print("=" * 70)
|
|
129
|
+
|
|
130
|
+
total_files = len(results)
|
|
131
|
+
successful_submissions = sum(1 for r in results if r.get("detect_id"))
|
|
132
|
+
errors = sum(1 for r in results if r.get("status") == "error")
|
|
133
|
+
completed = sum(1 for r in results if r.get("status") in ("done", "failed"))
|
|
134
|
+
pending = sum(1 for r in results if r.get("status") in ("submitted", "pending"))
|
|
135
|
+
|
|
136
|
+
print(f"\nTotal files processed: {total_files}")
|
|
137
|
+
print(f"Successful submissions: {successful_submissions}")
|
|
138
|
+
print(f"Errors: {errors}")
|
|
139
|
+
print(f"Completed (done/failed): {completed}")
|
|
140
|
+
print(f"Pending/Submitted: {pending}")
|
|
141
|
+
|
|
142
|
+
if successful_submissions > 0:
|
|
143
|
+
total_processing_time = sum(r.get("total_time", 0) for r in results)
|
|
144
|
+
|
|
145
|
+
print("\n" + "-" * 70)
|
|
146
|
+
print("Timing Statistics:")
|
|
147
|
+
print(f" Average total time: {(total_processing_time / successful_submissions):.3f}s")
|
|
148
|
+
|
|
149
|
+
failed_files = [r for r in results if r.get("status") == "error" or r.get("error_message")]
|
|
150
|
+
if failed_files:
|
|
151
|
+
print("\n" + "-" * 70)
|
|
152
|
+
print("Failed Files:")
|
|
153
|
+
for result in failed_files:
|
|
154
|
+
filename = result.get("filename", "unknown")
|
|
155
|
+
error = result.get("error_message", "Unknown error")
|
|
156
|
+
print(f" - {filename}: {error}")
|
|
157
|
+
|
|
158
|
+
print("\n" + "=" * 70)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main() -> None:
|
|
162
|
+
"""Main test function."""
|
|
163
|
+
print("\n" + "=" * 70)
|
|
164
|
+
print(" Image Detection Client Test Script - Batch Processing")
|
|
165
|
+
print("=" * 70)
|
|
166
|
+
|
|
167
|
+
# For local testing
|
|
168
|
+
# BASE_URL = 'http://localhost:8000'
|
|
169
|
+
|
|
170
|
+
# For development
|
|
171
|
+
# BASE_URL = 'https://ai-image-detector-dev-api-server-zo6e9.ondigitalocean.app'
|
|
172
|
+
|
|
173
|
+
# For production
|
|
174
|
+
BASE_URL = 'https://ai-image-detect.undetectable.ai'
|
|
175
|
+
|
|
176
|
+
API_KEY = "<YOUR API KEY>"
|
|
177
|
+
|
|
178
|
+
# Folder path
|
|
179
|
+
FOLDER = "./test_images/"
|
|
180
|
+
|
|
181
|
+
# Allow folder path as command line argument
|
|
182
|
+
if len(sys.argv) > 1:
|
|
183
|
+
folder_arg = sys.argv[1]
|
|
184
|
+
try:
|
|
185
|
+
stats = os.stat(folder_arg)
|
|
186
|
+
if os.path.isdir(folder_arg):
|
|
187
|
+
FOLDER = folder_arg
|
|
188
|
+
elif os.path.isfile(folder_arg):
|
|
189
|
+
FOLDER = os.path.dirname(folder_arg)
|
|
190
|
+
else:
|
|
191
|
+
print(f"Invalid path: {folder_arg}")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
except Exception:
|
|
194
|
+
print(f"Invalid path: {folder_arg}")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
ALLOWED_EXTS = {
|
|
198
|
+
'.jpg',
|
|
199
|
+
'.jpeg',
|
|
200
|
+
'.png',
|
|
201
|
+
'.bmp',
|
|
202
|
+
'.tiff',
|
|
203
|
+
'.webp',
|
|
204
|
+
'.heic',
|
|
205
|
+
'.heif',
|
|
206
|
+
'.avif',
|
|
207
|
+
'.jfif',
|
|
208
|
+
'.tif',
|
|
209
|
+
'.svg',
|
|
210
|
+
'.gif',
|
|
211
|
+
}
|
|
212
|
+
MIN_BYTES = 1 * 1024
|
|
213
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
214
|
+
|
|
215
|
+
log('Initializing ImageDetectionClient...')
|
|
216
|
+
client = ImageDetectionClient(API_KEY, BASE_URL)
|
|
217
|
+
|
|
218
|
+
initial_credits = None
|
|
219
|
+
try:
|
|
220
|
+
log('Checking initial credits...')
|
|
221
|
+
initial_credits = client.check_user_credits()
|
|
222
|
+
log(
|
|
223
|
+
f"Initial credits - Base: {initial_credits['baseCredits']}, "
|
|
224
|
+
f"Boost: {initial_credits['boostCredits']}, "
|
|
225
|
+
f"Total: {initial_credits['credits']}"
|
|
226
|
+
)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
if e is not None:
|
|
229
|
+
log(f"Warning: Could not check initial credits: {str(e)}")
|
|
230
|
+
else:
|
|
231
|
+
log(f"Warning: Could not check initial credits: {str(e)}")
|
|
232
|
+
|
|
233
|
+
log(f"\nSearching for image files in: {FOLDER}")
|
|
234
|
+
if not os.path.exists(FOLDER):
|
|
235
|
+
print(f"Folder not found: {FOLDER}")
|
|
236
|
+
print('\nUsage:')
|
|
237
|
+
print(' python test_api_client.py [folder_path]')
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
files = discover_image_files(FOLDER, ALLOWED_EXTS, MIN_BYTES, MAX_BYTES)
|
|
241
|
+
|
|
242
|
+
if len(files) == 0:
|
|
243
|
+
print(f"No valid image files found in: {FOLDER}")
|
|
244
|
+
print(f"\nAllowed extensions: {', '.join(sorted(ALLOWED_EXTS))}")
|
|
245
|
+
print(f"File size limits: {MIN_BYTES} bytes - {MAX_BYTES} bytes")
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
|
|
248
|
+
log(f"Found {len(files)} files to process")
|
|
249
|
+
|
|
250
|
+
results: List[Dict[str, Any]] = []
|
|
251
|
+
|
|
252
|
+
for file_name in files:
|
|
253
|
+
result = process_file(client, FOLDER, file_name)
|
|
254
|
+
results.append(result)
|
|
255
|
+
print() # Empty line between files
|
|
256
|
+
|
|
257
|
+
print_summary(results)
|
|
258
|
+
|
|
259
|
+
final_credits = None
|
|
260
|
+
try:
|
|
261
|
+
log('\nChecking final credits...')
|
|
262
|
+
final_credits = client.check_user_credits()
|
|
263
|
+
log(
|
|
264
|
+
f"Final credits - Base: {final_credits['baseCredits']}, "
|
|
265
|
+
f"Boost: {final_credits['boostCredits']}, "
|
|
266
|
+
f"Total: {final_credits['credits']}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if initial_credits and final_credits:
|
|
270
|
+
credits_charged = initial_credits['credits'] - final_credits['credits']
|
|
271
|
+
print('\n' + '-' * 70)
|
|
272
|
+
print('CREDIT USAGE SUMMARY')
|
|
273
|
+
print('-' * 70)
|
|
274
|
+
print(f"Initial Credits: {initial_credits['credits']}")
|
|
275
|
+
print(f"Final Credits: {final_credits['credits']}")
|
|
276
|
+
print(f"Credits Used: {credits_charged}")
|
|
277
|
+
print('-' * 70)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
if e is not None:
|
|
280
|
+
log(f"Warning: Could not check final credits: {str(e)}")
|
|
281
|
+
else:
|
|
282
|
+
log(f"Warning: Could not check final credits: {str(e)}")
|
|
283
|
+
|
|
284
|
+
output_file = 'api_client_test_results.json'
|
|
285
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
286
|
+
json.dump(results, f, ensure_ascii=False, indent=2)
|
|
287
|
+
log(f"\nResults saved to: {output_file}")
|
|
288
|
+
|
|
289
|
+
print('\n' + '=' * 70)
|
|
290
|
+
print(' All processing completed!')
|
|
291
|
+
print('=' * 70)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
try:
|
|
296
|
+
main()
|
|
297
|
+
except KeyboardInterrupt:
|
|
298
|
+
print("\n\n Processing interrupted by user")
|
|
299
|
+
sys.exit(1)
|
|
300
|
+
except Exception as error:
|
|
301
|
+
print(f"\n Unexpected error: {error}")
|
|
302
|
+
import traceback
|
|
303
|
+
traceback.print_exc()
|
|
304
|
+
sys.exit(1)
|