modotte 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modotte/AIRealNet.py +6 -0
- modotte/__init__.py +4 -0
- modotte/_common.py +108 -0
- modotte/_formats.py +180 -0
- modotte/audio.py +182 -0
- modotte/image.py +83 -0
- modotte-1.0.0.dist-info/METADATA +360 -0
- modotte-1.0.0.dist-info/RECORD +11 -0
- modotte-1.0.0.dist-info/WHEEL +5 -0
- modotte-1.0.0.dist-info/licenses/LICENSE +21 -0
- modotte-1.0.0.dist-info/top_level.txt +1 -0
modotte/AIRealNet.py
ADDED
modotte/__init__.py
ADDED
modotte/_common.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shared helpers: model directory lookup, download and pipeline loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Optional, Tuple, cast
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
_cache: Dict[Tuple[str, str], Tuple[Any, str]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def home_dir() -> Path:
|
|
17
|
+
try:
|
|
18
|
+
return Path.home()
|
|
19
|
+
except (RuntimeError, KeyError):
|
|
20
|
+
return Path(os.path.expanduser("~"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def model_exists(path: Path) -> bool:
|
|
24
|
+
return path.is_dir() and (path / "config.json").is_file()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_device() -> str:
|
|
28
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_hf_token(hf_token: Optional[str] = None) -> Optional[str]:
|
|
32
|
+
"""Return the Hugging Face token to use, or ``None`` if there isn't one.
|
|
33
|
+
|
|
34
|
+
Order of precedence:
|
|
35
|
+
1. the ``hf_token`` argument passed by the caller,
|
|
36
|
+
2. the ``HF_TOKEN`` (or legacy ``HUGGING_FACE_HUB_TOKEN``) environment
|
|
37
|
+
variable, or a token saved with ``hf auth login``
|
|
38
|
+
(both handled by ``huggingface_hub.get_token``).
|
|
39
|
+
"""
|
|
40
|
+
if hf_token is not None:
|
|
41
|
+
if not isinstance(hf_token, str):
|
|
42
|
+
raise TypeError(
|
|
43
|
+
f"hf_token must be a string, got {type(hf_token).__name__}."
|
|
44
|
+
)
|
|
45
|
+
hf_token = hf_token.strip()
|
|
46
|
+
if hf_token:
|
|
47
|
+
return hf_token
|
|
48
|
+
|
|
49
|
+
from huggingface_hub import get_token
|
|
50
|
+
|
|
51
|
+
return get_token() or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def download_model(
|
|
55
|
+
repo_id: str, model_dir: Path, hf_token: Optional[str] = None
|
|
56
|
+
) -> None:
|
|
57
|
+
from huggingface_hub import snapshot_download
|
|
58
|
+
|
|
59
|
+
token = resolve_hf_token(hf_token)
|
|
60
|
+
if token is None:
|
|
61
|
+
print(
|
|
62
|
+
"[modotte] Tip: no Hugging Face token found. Set the HF_TOKEN "
|
|
63
|
+
"environment variable or pass hf_token=\"hf_...\" for higher rate "
|
|
64
|
+
"limits and faster downloads "
|
|
65
|
+
"(create one at https://huggingface.co/settings/tokens)."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
print(f"[modotte] Model not found. Downloading {repo_id} to {model_dir} ...")
|
|
70
|
+
snapshot_download(repo_id=repo_id, local_dir=str(model_dir), token=token)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_pipeline(
|
|
74
|
+
task: str,
|
|
75
|
+
repo_id: str,
|
|
76
|
+
model_dir: Path,
|
|
77
|
+
hf_token: Optional[str] = None,
|
|
78
|
+
**pipeline_kwargs,
|
|
79
|
+
):
|
|
80
|
+
"""Return (pipeline, device); download the model first if it is missing.
|
|
81
|
+
|
|
82
|
+
``hf_token`` is only used for the download (the model is loaded from the
|
|
83
|
+
local ``model_dir``). Pipelines are cached per (task, model_dir), so the
|
|
84
|
+
check/download/load only happens the first time a class is initialized.
|
|
85
|
+
"""
|
|
86
|
+
key = (task, str(model_dir))
|
|
87
|
+
with _lock:
|
|
88
|
+
if key in _cache:
|
|
89
|
+
return _cache[key]
|
|
90
|
+
|
|
91
|
+
if not model_exists(model_dir):
|
|
92
|
+
download_model(repo_id, model_dir, hf_token)
|
|
93
|
+
if not model_exists(model_dir):
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
f"Download finished but no model was found in {model_dir}."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
from transformers import pipeline
|
|
99
|
+
|
|
100
|
+
device = detect_device()
|
|
101
|
+
print(f"[modotte] Using device: {device}")
|
|
102
|
+
# `pipeline` is typed with one overload per literal task name, so a
|
|
103
|
+
# runtime `task` string can't match; cast to Any for the type checker.
|
|
104
|
+
pipe = cast(Any, pipeline)(
|
|
105
|
+
task, model=str(model_dir), device=device, **pipeline_kwargs
|
|
106
|
+
)
|
|
107
|
+
_cache[key] = (pipe, device)
|
|
108
|
+
return _cache[key]
|
modotte/_formats.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""File-format support: extension checks and audio/image decoding.
|
|
2
|
+
|
|
3
|
+
Audio: MP3, WAV, AAC, FLAC, OGG, Opus, M4A, AIFF
|
|
4
|
+
Image: JPG, JPEG, PNG, WebP, GIF, SVG, BMP, TIFF, AVIF, HEIC, HEIF
|
|
5
|
+
|
|
6
|
+
Heavy optional decoders (soundfile, PyAV, cairosvg, pillow-heif) are imported
|
|
7
|
+
lazily so that only the formats you actually use need them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Tuple, Union
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
PathLike = Union[str, os.PathLike]
|
|
20
|
+
|
|
21
|
+
AUDIO_EXTENSIONS: Tuple[str, ...] = (
|
|
22
|
+
".mp3", ".wav", ".aac", ".flac", ".ogg", ".opus", ".m4a", ".aiff", ".aif",
|
|
23
|
+
)
|
|
24
|
+
IMAGE_EXTENSIONS: Tuple[str, ...] = (
|
|
25
|
+
".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".bmp",
|
|
26
|
+
".tif", ".tiff", ".avif", ".heic", ".heif",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
AUDIO_FORMATS = "MP3, WAV, AAC, FLAC, OGG, Opus, M4A, AIFF"
|
|
30
|
+
IMAGE_FORMATS = "JPG, JPEG, PNG, WebP, GIF, SVG, BMP, TIFF, AVIF, HEIC, HEIF"
|
|
31
|
+
|
|
32
|
+
SVG_RENDER_WIDTH = 512
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_extension(path: PathLike, allowed: Tuple[str, ...], kind: str, formats: str) -> str:
|
|
36
|
+
ext = Path(path).suffix.lower()
|
|
37
|
+
if ext not in allowed:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Unsupported {kind} format '{ext or '(no extension)'}'. "
|
|
40
|
+
f"Supported formats: {formats}."
|
|
41
|
+
)
|
|
42
|
+
return ext
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ----------------------------------------------------------------------
|
|
46
|
+
# Audio
|
|
47
|
+
# ----------------------------------------------------------------------
|
|
48
|
+
def _decode_with_av(path: PathLike, target_sr: int) -> np.ndarray:
|
|
49
|
+
"""Decode any ffmpeg-supported audio (AAC, M4A, ...) to mono float32."""
|
|
50
|
+
import av
|
|
51
|
+
|
|
52
|
+
container = av.open(str(path))
|
|
53
|
+
try:
|
|
54
|
+
stream = next((s for s in container.streams if s.type == "audio"), None)
|
|
55
|
+
if stream is None:
|
|
56
|
+
raise ValueError("No audio stream found in file.")
|
|
57
|
+
|
|
58
|
+
resampler = av.AudioResampler(format="flt", layout="mono", rate=target_sr)
|
|
59
|
+
parts = []
|
|
60
|
+
|
|
61
|
+
def collect(frames):
|
|
62
|
+
if frames is None:
|
|
63
|
+
return
|
|
64
|
+
if not isinstance(frames, (list, tuple)):
|
|
65
|
+
frames = [frames]
|
|
66
|
+
for f in frames:
|
|
67
|
+
parts.append(f.to_ndarray().reshape(-1))
|
|
68
|
+
|
|
69
|
+
for frame in container.decode(stream):
|
|
70
|
+
collect(resampler.resample(frame)) # type: ignore
|
|
71
|
+
collect(resampler.resample(None)) # flush
|
|
72
|
+
finally:
|
|
73
|
+
container.close()
|
|
74
|
+
|
|
75
|
+
if not parts:
|
|
76
|
+
return np.zeros(0, dtype=np.float32)
|
|
77
|
+
return np.concatenate(parts).astype(np.float32)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_audio(path: PathLike, target_sr: int) -> np.ndarray:
|
|
81
|
+
"""Read an audio file as mono float32 at ``target_sr``."""
|
|
82
|
+
check_extension(path, AUDIO_EXTENSIONS, "audio", AUDIO_FORMATS)
|
|
83
|
+
|
|
84
|
+
sf_error = None
|
|
85
|
+
try:
|
|
86
|
+
import soundfile as sf
|
|
87
|
+
from scipy.signal import resample_poly
|
|
88
|
+
|
|
89
|
+
audio, sr = sf.read(str(path), always_2d=False)
|
|
90
|
+
audio = np.asarray(audio, dtype=np.float32)
|
|
91
|
+
if audio.ndim == 2:
|
|
92
|
+
audio = audio.mean(axis=1)
|
|
93
|
+
if sr != target_sr:
|
|
94
|
+
audio = resample_poly(audio, target_sr, sr).astype(np.float32)
|
|
95
|
+
return audio
|
|
96
|
+
except Exception as exc: # AAC/M4A (and some MP3 builds) land here
|
|
97
|
+
sf_error = exc
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return _decode_with_av(path, target_sr)
|
|
101
|
+
except ImportError as exc:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"Could not decode '{Path(path).name}' with soundfile ({sf_error}) and "
|
|
104
|
+
f"PyAV is not installed. Install it with: pip install av"
|
|
105
|
+
) from exc
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"Could not decode '{Path(path).name}': soundfile error: {sf_error}; "
|
|
109
|
+
f"PyAV error: {exc}"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ----------------------------------------------------------------------
|
|
114
|
+
# Images
|
|
115
|
+
# ----------------------------------------------------------------------
|
|
116
|
+
_plugins_registered = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _register_image_plugins() -> None:
|
|
120
|
+
"""Enable HEIC/HEIF/AVIF in Pillow when the plugins are installed."""
|
|
121
|
+
global _plugins_registered
|
|
122
|
+
if _plugins_registered:
|
|
123
|
+
return
|
|
124
|
+
_plugins_registered = True
|
|
125
|
+
try:
|
|
126
|
+
from pillow_heif import register_heif_opener
|
|
127
|
+
|
|
128
|
+
register_heif_opener()
|
|
129
|
+
except ImportError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def flatten_to_rgb(img):
|
|
134
|
+
"""Convert to RGB, compositing transparency onto white."""
|
|
135
|
+
from PIL import Image as PILImage
|
|
136
|
+
|
|
137
|
+
has_alpha = img.mode in ("RGBA", "LA", "PA") or (
|
|
138
|
+
img.mode == "P" and "transparency" in img.info
|
|
139
|
+
)
|
|
140
|
+
if has_alpha:
|
|
141
|
+
rgba = img.convert("RGBA")
|
|
142
|
+
background = PILImage.new("RGB", rgba.size, (255, 255, 255))
|
|
143
|
+
background.paste(rgba, mask=rgba.getchannel("A"))
|
|
144
|
+
return background
|
|
145
|
+
return img.convert("RGB")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_image(path: PathLike):
|
|
149
|
+
"""Open an image file of any supported format as an RGB PIL image."""
|
|
150
|
+
from PIL import Image as PILImage
|
|
151
|
+
from PIL import ImageOps
|
|
152
|
+
|
|
153
|
+
ext = check_extension(path, IMAGE_EXTENSIONS, "image", IMAGE_FORMATS)
|
|
154
|
+
|
|
155
|
+
if ext == ".svg":
|
|
156
|
+
try:
|
|
157
|
+
import cairosvg
|
|
158
|
+
except ImportError as exc:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
"SVG support requires cairosvg. Install it with: pip install cairosvg"
|
|
161
|
+
) from exc
|
|
162
|
+
png = cairosvg.svg2png(
|
|
163
|
+
url=str(path), output_width=SVG_RENDER_WIDTH, background_color="white"
|
|
164
|
+
)
|
|
165
|
+
with PILImage.open(io.BytesIO(png)) as img: # type: ignore
|
|
166
|
+
return flatten_to_rgb(img)
|
|
167
|
+
|
|
168
|
+
_register_image_plugins()
|
|
169
|
+
try:
|
|
170
|
+
with PILImage.open(path) as img:
|
|
171
|
+
img.seek(0) # first frame of GIF / multi-page TIFF
|
|
172
|
+
img = ImageOps.exif_transpose(img) # honour camera orientation
|
|
173
|
+
return flatten_to_rgb(img)
|
|
174
|
+
except PILImage.UnidentifiedImageError as exc:
|
|
175
|
+
hint = ""
|
|
176
|
+
if ext in (".heic", ".heif"):
|
|
177
|
+
hint = " Install HEIC/HEIF support with: pip install pillow-heif"
|
|
178
|
+
elif ext == ".avif":
|
|
179
|
+
hint = " Install AVIF support with: pip install pillow-heif (or Pillow >= 11.3)"
|
|
180
|
+
raise RuntimeError(f"Could not read image '{Path(path).name}'.{hint}") from exc
|
modotte/audio.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Audio classification: AI-generated vs. human voice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._common import home_dir, load_pipeline
|
|
12
|
+
from ._formats import AUDIO_EXTENSIONS, AUDIO_FORMATS, read_audio
|
|
13
|
+
|
|
14
|
+
MODEL_ID = "Modotte/AIRealNet-Audio"
|
|
15
|
+
MODEL_DIR_NAME = "AIRealNetAudio"
|
|
16
|
+
SAMPLE_RATE = 16_000
|
|
17
|
+
CHUNK_SECONDS = 12
|
|
18
|
+
CHUNK_SAMPLES = SAMPLE_RATE * CHUNK_SECONDS
|
|
19
|
+
DEFAULT_THRESHOLD = 0.50
|
|
20
|
+
|
|
21
|
+
__all__ = ["Audio"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_threshold(threshold: Any) -> float:
|
|
25
|
+
"""Threshold must be a float in the range 0.0 to 1.0 (inclusive)."""
|
|
26
|
+
if isinstance(threshold, bool) or not isinstance(threshold, (float, np.floating)):
|
|
27
|
+
raise TypeError(
|
|
28
|
+
f"threshold must be a float between 0.0 and 1.0, "
|
|
29
|
+
f"got {type(threshold).__name__}."
|
|
30
|
+
)
|
|
31
|
+
value = float(threshold)
|
|
32
|
+
if not (0.0 <= value <= 1.0): # also rejects NaN
|
|
33
|
+
raise ValueError(f"threshold must be between 0.0 and 1.0, got {value}.")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Audio:
|
|
38
|
+
"""Audio classifier that scores speech as AI-generated or human.
|
|
39
|
+
|
|
40
|
+
On first initialization ``~/AIRealNetAudio`` is checked; if the model is
|
|
41
|
+
missing it is downloaded there. Runs on CUDA if available, else CPU.
|
|
42
|
+
|
|
43
|
+
``hf_token`` (optional) is a Hugging Face access token used for the model
|
|
44
|
+
download, giving higher rate limits and faster downloads. If omitted, the
|
|
45
|
+
``HF_TOKEN`` environment variable (or a token saved via ``hf auth login``)
|
|
46
|
+
is used when available.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
SUPPORTED_FORMATS = AUDIO_FORMATS
|
|
50
|
+
SUPPORTED_EXTENSIONS = AUDIO_EXTENSIONS
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
model_dir: Optional[Union[str, os.PathLike]] = None,
|
|
55
|
+
hf_token: Optional[str] = None,
|
|
56
|
+
):
|
|
57
|
+
self.model_dir = Path(model_dir) if model_dir else home_dir() / MODEL_DIR_NAME
|
|
58
|
+
self._pipeline, self.device = load_pipeline(
|
|
59
|
+
"audio-classification",
|
|
60
|
+
MODEL_ID,
|
|
61
|
+
self.model_dir,
|
|
62
|
+
hf_token=hf_token,
|
|
63
|
+
trust_remote_code=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def load_audio(path: Union[str, os.PathLike]) -> np.ndarray:
|
|
68
|
+
audio = read_audio(path, SAMPLE_RATE)
|
|
69
|
+
|
|
70
|
+
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
|
71
|
+
if peak > 1.0:
|
|
72
|
+
audio = audio / peak
|
|
73
|
+
|
|
74
|
+
return audio
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def chunk_audio(audio: np.ndarray) -> List[np.ndarray]:
|
|
78
|
+
if len(audio) == 0:
|
|
79
|
+
raise ValueError("The audio file is empty.")
|
|
80
|
+
|
|
81
|
+
chunks = []
|
|
82
|
+
for start in range(0, len(audio), CHUNK_SAMPLES):
|
|
83
|
+
chunk = audio[start:start + CHUNK_SAMPLES]
|
|
84
|
+
# The model is trained on 12-second chunks; pad the last one.
|
|
85
|
+
if len(chunk) < CHUNK_SAMPLES:
|
|
86
|
+
chunk = np.pad(chunk, (0, CHUNK_SAMPLES - len(chunk)), mode="constant")
|
|
87
|
+
chunks.append(chunk.astype(np.float32))
|
|
88
|
+
return chunks
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def get_label_scores(result: List[Dict[str, Any]]) -> Tuple[float, float]:
|
|
92
|
+
ai = 0.0
|
|
93
|
+
human = 0.0
|
|
94
|
+
|
|
95
|
+
for item in result:
|
|
96
|
+
label = str(item["label"]).lower()
|
|
97
|
+
score = float(item["score"])
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
label in {"aivoice", "ai", "fake", "spoof", "ai-generated"}
|
|
101
|
+
or "ai" in label
|
|
102
|
+
or "spoof" in label
|
|
103
|
+
):
|
|
104
|
+
ai = score
|
|
105
|
+
elif (
|
|
106
|
+
label in {"humanvoice", "human", "real"}
|
|
107
|
+
or "human" in label
|
|
108
|
+
or "real" in label
|
|
109
|
+
):
|
|
110
|
+
human = score
|
|
111
|
+
|
|
112
|
+
# Fallback for unexpected model labels.
|
|
113
|
+
if ai == 0.0 and human == 0.0 and result:
|
|
114
|
+
top = max(result, key=lambda x: x["score"])
|
|
115
|
+
top_label = str(top["label"]).lower()
|
|
116
|
+
if "ai" in top_label or "fake" in top_label or "spoof" in top_label:
|
|
117
|
+
ai = float(top["score"])
|
|
118
|
+
human = 1.0 - ai
|
|
119
|
+
else:
|
|
120
|
+
human = float(top["score"])
|
|
121
|
+
ai = 1.0 - human
|
|
122
|
+
|
|
123
|
+
return ai, human
|
|
124
|
+
|
|
125
|
+
def predict(
|
|
126
|
+
self,
|
|
127
|
+
path: Union[str, os.PathLike],
|
|
128
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
129
|
+
) -> Dict[str, Any]:
|
|
130
|
+
"""Classify an audio file.
|
|
131
|
+
|
|
132
|
+
Supported formats: MP3, WAV, AAC, FLAC, OGG, Opus, M4A, AIFF.
|
|
133
|
+
|
|
134
|
+
``threshold`` must be a float from 0.0 to 1.0 (inclusive); other
|
|
135
|
+
types raise ``TypeError`` and out-of-range values raise ``ValueError``.
|
|
136
|
+
|
|
137
|
+
Returns ``label`` ("AI" or "Human"), ``is_ai``, ``confidence``,
|
|
138
|
+
``ai_score``, ``human_score``, ``duration``, ``threshold``,
|
|
139
|
+
``device`` and per-chunk ``chunks`` details.
|
|
140
|
+
"""
|
|
141
|
+
threshold = _validate_threshold(threshold)
|
|
142
|
+
|
|
143
|
+
if not path:
|
|
144
|
+
raise ValueError("Please provide an audio file path.")
|
|
145
|
+
if not Path(path).is_file():
|
|
146
|
+
raise FileNotFoundError(f"Audio file not found: {path}")
|
|
147
|
+
|
|
148
|
+
audio = self.load_audio(path)
|
|
149
|
+
chunks = self.chunk_audio(audio)
|
|
150
|
+
|
|
151
|
+
ai_scores, human_scores, rows = [], [], []
|
|
152
|
+
for idx, chunk in enumerate(chunks, start=1):
|
|
153
|
+
result = self._pipeline({"array": chunk, "sampling_rate": SAMPLE_RATE})
|
|
154
|
+
ai, human = self.get_label_scores(result)
|
|
155
|
+
ai_scores.append(ai)
|
|
156
|
+
human_scores.append(human)
|
|
157
|
+
rows.append(
|
|
158
|
+
{
|
|
159
|
+
"chunk": idx,
|
|
160
|
+
"start": (idx - 1) * CHUNK_SECONDS,
|
|
161
|
+
"ai_score": round(ai, 4),
|
|
162
|
+
"human_score": round(human, 4),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
ai_score = float(np.mean(ai_scores))
|
|
167
|
+
human_score = float(np.mean(human_scores))
|
|
168
|
+
is_ai = ai_score >= threshold
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"label": "AI" if is_ai else "Human",
|
|
172
|
+
"is_ai": is_ai,
|
|
173
|
+
"confidence": ai_score if is_ai else human_score,
|
|
174
|
+
"ai_score": ai_score,
|
|
175
|
+
"human_score": human_score,
|
|
176
|
+
"duration": len(audio) / SAMPLE_RATE,
|
|
177
|
+
"threshold": threshold,
|
|
178
|
+
"device": self.device,
|
|
179
|
+
"chunks": rows,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
__call__ = predict
|
modotte/image.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Image classification: AI-generated vs. real photographs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image as PILImage
|
|
11
|
+
|
|
12
|
+
from ._common import home_dir, load_pipeline
|
|
13
|
+
from ._formats import IMAGE_EXTENSIONS, IMAGE_FORMATS, flatten_to_rgb, read_image
|
|
14
|
+
|
|
15
|
+
MODEL_ID = "Modotte/AIRealNet"
|
|
16
|
+
MODEL_DIR_NAME = "AIRealNetImage"
|
|
17
|
+
|
|
18
|
+
ImageInput = Union[str, os.PathLike, PILImage.Image, np.ndarray]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Image:
|
|
22
|
+
"""AIRealNet (SwinV2 Tiny) binary image classifier.
|
|
23
|
+
|
|
24
|
+
Class 0: AI-generated image ("artificial")
|
|
25
|
+
Class 1: Real human image ("real")
|
|
26
|
+
|
|
27
|
+
On first initialization ``~/AIRealNetImage`` is checked; if the model is
|
|
28
|
+
missing it is downloaded there. Runs on CUDA if available, else CPU.
|
|
29
|
+
|
|
30
|
+
``hf_token`` (optional) is a Hugging Face access token used for the model
|
|
31
|
+
download, giving higher rate limits and faster downloads. If omitted, the
|
|
32
|
+
``HF_TOKEN`` environment variable (or a token saved via ``hf auth login``)
|
|
33
|
+
is used when available.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SUPPORTED_FORMATS = IMAGE_FORMATS
|
|
37
|
+
SUPPORTED_EXTENSIONS = IMAGE_EXTENSIONS
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
model_dir: Optional[Union[str, os.PathLike]] = None,
|
|
42
|
+
hf_token: Optional[str] = None,
|
|
43
|
+
):
|
|
44
|
+
self.model_dir = Path(model_dir) if model_dir else home_dir() / MODEL_DIR_NAME
|
|
45
|
+
self._pipeline, self.device = load_pipeline(
|
|
46
|
+
"image-classification", MODEL_ID, self.model_dir, hf_token=hf_token
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _to_pil(image: ImageInput) -> PILImage.Image:
|
|
51
|
+
if isinstance(image, PILImage.Image):
|
|
52
|
+
return flatten_to_rgb(image)
|
|
53
|
+
if isinstance(image, np.ndarray):
|
|
54
|
+
return flatten_to_rgb(PILImage.fromarray(image))
|
|
55
|
+
if not Path(image).is_file():
|
|
56
|
+
raise FileNotFoundError(f"Image file not found: {image}")
|
|
57
|
+
return read_image(image)
|
|
58
|
+
|
|
59
|
+
def predict(self, image: ImageInput) -> Dict[str, Any]:
|
|
60
|
+
"""Classify an image (path, PIL image or numpy array).
|
|
61
|
+
|
|
62
|
+
Supported files: JPG, JPEG, PNG, WebP, GIF, SVG, BMP, TIFF, AVIF, HEIC, HEIF.
|
|
63
|
+
|
|
64
|
+
Returns ``label`` (top class, e.g. "artificial" or "real"),
|
|
65
|
+
``confidence``, ``is_ai``, and ``scores`` such as
|
|
66
|
+
``{'artificial': 0.94, 'real': 0.06}``.
|
|
67
|
+
"""
|
|
68
|
+
if image is None:
|
|
69
|
+
raise ValueError("Please provide an image.")
|
|
70
|
+
|
|
71
|
+
results = self._pipeline(self._to_pil(image))
|
|
72
|
+
scores = {r["label"]: round(float(r["score"]), 3) for r in results}
|
|
73
|
+
label = max(results, key=lambda r: r["score"])["label"]
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"label": label,
|
|
77
|
+
"confidence": scores[label],
|
|
78
|
+
"is_ai": label.lower() in {"artificial", "ai", "fake"} or "artificial" in label.lower(),
|
|
79
|
+
"scores": scores,
|
|
80
|
+
"device": self.device,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
__call__ = predict
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modotte
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A universal Python package for accessing and using all AI models trained by Modotte.
|
|
5
|
+
Author: Sujal Rajpoot, Parvesh Rawal
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 modotte-python
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Repository, https://github.com/Modotte/modotte-python
|
|
29
|
+
Project-URL: Issues, https://github.com/Modotte/modotte-python/issues
|
|
30
|
+
Keywords: ai,artificial-intelligence,machine-learning,deep-learning,models,modotte,pytorch,transformers
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: numpy
|
|
45
|
+
Requires-Dist: scipy
|
|
46
|
+
Requires-Dist: soundfile
|
|
47
|
+
Requires-Dist: pillow>=10.0
|
|
48
|
+
Requires-Dist: pillow-heif
|
|
49
|
+
Requires-Dist: cairosvg
|
|
50
|
+
Requires-Dist: av
|
|
51
|
+
Requires-Dist: torch
|
|
52
|
+
Requires-Dist: transformers
|
|
53
|
+
Requires-Dist: huggingface_hub
|
|
54
|
+
Dynamic: license-file
|
|
55
|
+
|
|
56
|
+
# modotte-python
|
|
57
|
+
|
|
58
|
+
A universal Python package for accessing and using all AI models trained by Modotte.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
### Install from PyPI
|
|
63
|
+
|
|
64
|
+
Install the latest stable version directly from PyPI:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install modotte
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then import Modotte in your Python project:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from modotte import Audio, Image
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Upgrade to the latest version
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install --upgrade modotte
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Install from GitHub
|
|
85
|
+
|
|
86
|
+
You can also install the latest development version directly from GitHub:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install git+https://github.com/Modotte/modotte-python.git
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Clone the repository
|
|
93
|
+
|
|
94
|
+
If you want to develop or modify Modotte locally:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/Modotte/modotte-python.git
|
|
98
|
+
cd modotte-python
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Project Structure
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
modotte-python/
|
|
107
|
+
├── 📄 .gitignore
|
|
108
|
+
├── 📄 LICENSE
|
|
109
|
+
├── 📄 README.md
|
|
110
|
+
├── 📄 pyproject.toml
|
|
111
|
+
└── 📁 src/
|
|
112
|
+
└── 📁 modotte/
|
|
113
|
+
├── 📄 AIRealNet.py
|
|
114
|
+
├── 📄 __init__.py
|
|
115
|
+
├── 📄 _common.py ← internal
|
|
116
|
+
├── 📄 _formats.py ← internal
|
|
117
|
+
├── 📄 audio.py ← public API
|
|
118
|
+
└── 📄 image.py ← public API
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Hugging Face Token (Optional, Recommended)
|
|
122
|
+
|
|
123
|
+
The models are downloaded from the Hugging Face Hub on first use.
|
|
124
|
+
|
|
125
|
+
Without a token, you may see:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
Warning: You are sending unauthenticated requests to the HF Hub
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Unauthenticated requests are subject to lower rate limits. A free Hugging Face token can provide higher rate limits and more reliable downloads.
|
|
132
|
+
|
|
133
|
+
Create a token from:
|
|
134
|
+
|
|
135
|
+
https://huggingface.co/settings/tokens
|
|
136
|
+
|
|
137
|
+
There are three ways to provide it.
|
|
138
|
+
|
|
139
|
+
### 1. Pass the token directly
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from modotte import Audio, Image
|
|
143
|
+
|
|
144
|
+
detector = Audio(hf_token="hf_xxxxxxxxxxxxxxxx")
|
|
145
|
+
detector = Image(hf_token="hf_xxxxxxxxxxxxxxxx")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 2. Set the `HF_TOKEN` environment variable
|
|
149
|
+
|
|
150
|
+
#### Windows PowerShell — current session
|
|
151
|
+
|
|
152
|
+
```powershell
|
|
153
|
+
$env:HF_TOKEN = "hf_xxxxxxxxxxxxxxxx"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### Windows — permanent
|
|
157
|
+
|
|
158
|
+
```powershell
|
|
159
|
+
setx HF_TOKEN "hf_xxxxxxxxxxxxxxxx"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Open a new terminal after running `setx`.
|
|
163
|
+
|
|
164
|
+
#### macOS / Linux
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
export HF_TOKEN="hf_xxxxxxxxxxxxxxxx"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### 3. Log in using the Hugging Face CLI
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
hf auth login
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
An explicit `hf_token=` argument takes priority over the `HF_TOKEN` environment variable.
|
|
177
|
+
|
|
178
|
+
The token is only used for downloading models. Modotte does not store or print your token.
|
|
179
|
+
|
|
180
|
+
Models are downloaded once and subsequently loaded from the local cache.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Audio Classification
|
|
185
|
+
|
|
186
|
+
Import the audio detector:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from modotte import Audio
|
|
190
|
+
|
|
191
|
+
detector = Audio()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
On first use, Modotte checks for the model locally and downloads it if necessary.
|
|
195
|
+
|
|
196
|
+
You can optionally provide a Hugging Face token:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
detector = Audio(hf_token="hf_xxxxxxxxxxxxxxxx")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Detect Bonafide / Real Audio
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
result = detector.predict("bonafide.wav", threshold=0.5)
|
|
206
|
+
|
|
207
|
+
print(result["label"])
|
|
208
|
+
print(result["confidence"])
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Detect Spoof / AI-Generated Audio
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
result = detector.predict("spoof.wav", threshold=0.5)
|
|
215
|
+
|
|
216
|
+
print(result["label"])
|
|
217
|
+
print(result["confidence"])
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The `threshold` parameter must be a float between `0.0` and `1.0`, inclusive.
|
|
221
|
+
|
|
222
|
+
Invalid types such as integers, strings, or booleans raise `TypeError`.
|
|
223
|
+
|
|
224
|
+
Values outside the range `0.0`–`1.0` raise `ValueError`.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Image Classification
|
|
229
|
+
|
|
230
|
+
Import the image detector:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from modotte import Image
|
|
234
|
+
|
|
235
|
+
detector = Image()
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
On first use, Modotte checks for the model locally and downloads it if necessary.
|
|
239
|
+
|
|
240
|
+
You can optionally provide a Hugging Face token:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
detector = Image(hf_token="hf_xxxxxxxxxxxxxxxx")
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Run a prediction:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
result = detector.predict("cat.jpg")
|
|
250
|
+
|
|
251
|
+
print(result["label"])
|
|
252
|
+
print(result["confidence"])
|
|
253
|
+
print(result["scores"])
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
{
|
|
260
|
+
"label": "real",
|
|
261
|
+
"confidence": 0.94,
|
|
262
|
+
"scores": {
|
|
263
|
+
"artificial": 0.06,
|
|
264
|
+
"real": 0.94
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`Modotte/AIRealNet` is a binary SwinV2 Tiny classifier:
|
|
270
|
+
|
|
271
|
+
* Class 0: AI-generated
|
|
272
|
+
* Class 1: Real human image
|
|
273
|
+
|
|
274
|
+
Both import styles are supported:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from modotte import Audio, Image
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
and:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from modotte.AIRealNet import Audio, Image
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Supported file types
|
|
289
|
+
|
|
290
|
+
- **Audio:** MP3, WAV, AAC, FLAC, OGG, Opus, M4A, AIFF
|
|
291
|
+
- **Image:** JPG, JPEG, PNG, WebP, GIF, SVG, BMP, TIFF, AVIF, HEIC, HEIF
|
|
292
|
+
|
|
293
|
+
Other extensions raise `ValueError`. WAV/FLAC/OGG/Opus/AIFF/MP3 are decoded with
|
|
294
|
+
`soundfile`; AAC and M4A (and any file `soundfile` can't read) fall back to PyAV,
|
|
295
|
+
which bundles ffmpeg. HEIC/HEIF/AVIF need `pillow-heif`, and SVG needs `cairosvg`
|
|
296
|
+
(which requires the Cairo library on some systems). GIF and multi-page TIFF use
|
|
297
|
+
the first frame; transparent images are composited on white.
|
|
298
|
+
|
|
299
|
+
Models load on CUDA when available, otherwise CPU.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Quick Start
|
|
304
|
+
|
|
305
|
+
After installing Modotte:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
pip install modotte
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
You can immediately use the supported models:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from modotte import Audio, Image
|
|
315
|
+
|
|
316
|
+
audio_detector = Audio()
|
|
317
|
+
image_detector = Image()
|
|
318
|
+
|
|
319
|
+
audio_result = audio_detector.predict("audio.wav")
|
|
320
|
+
image_result = image_detector.predict("image.jpg")
|
|
321
|
+
|
|
322
|
+
print(audio_result)
|
|
323
|
+
print(image_result)
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Development
|
|
329
|
+
|
|
330
|
+
Clone the repository:
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
git clone https://github.com/Modotte/modotte-python.git
|
|
334
|
+
cd modotte-python
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Create a virtual environment:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
python -m venv .venv
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Activate it and install the package in editable mode:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
pip install -e .
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
After making changes to the source code, the changes will be available immediately in your environment.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
## Authors
|
|
353
|
+
|
|
354
|
+
- **Sujal Rajpoot** — [GitHub](https://github.com/sujalrajpoot)
|
|
355
|
+
- **Parvesh Rawal** — [GitHub](https://github.com/Parveshiiii)
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## License
|
|
359
|
+
|
|
360
|
+
See the [LICENSE](LICENSE) file for licensing information.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
modotte/AIRealNet.py,sha256=-EkQHhDe1vqUBOXYfcUvVAwh9rT3NpDQqO4sFPvo5Yg,148
|
|
2
|
+
modotte/__init__.py,sha256=M51bKNoTjHjX9ivnY_XkF-r-BmzwEkUIRUNp16Tfh5A,88
|
|
3
|
+
modotte/_common.py,sha256=0cR0QKEKD6-88dXj_70iTvd-hQnU6Fd2648XW7khO7s,3446
|
|
4
|
+
modotte/_formats.py,sha256=oM0eD6RthKO3sAx3tmTx52MCSPRta1gtGXNhXIkt34I,6038
|
|
5
|
+
modotte/audio.py,sha256=EIfVuw_xDKWBGKtVIRN40eg4h9o8nN3OsuX-m_r7fg4,6140
|
|
6
|
+
modotte/image.py,sha256=j9X1jFzGzu0JkOk8tvZF58ENs3J-X6pXs_hUbxIiTzE,2897
|
|
7
|
+
modotte-1.0.0.dist-info/licenses/LICENSE,sha256=MGQJ5agwuqNNKHU9qlSAhyV64mksKZRNoUlJ_MID5T8,1092
|
|
8
|
+
modotte-1.0.0.dist-info/METADATA,sha256=D_n-CDdMXlUUD4DJv9d4vxwGQwK4pt1aBYiu8-ef5-s,8754
|
|
9
|
+
modotte-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
modotte-1.0.0.dist-info/top_level.txt,sha256=sscqzIvtSNpW6yZ83-ym4_F4MpLARGG4P0yZuq0mM4E,8
|
|
11
|
+
modotte-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 modotte-python
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modotte
|