minosse 0.1.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.
- minosse/__init__.py +1 -0
- minosse/__main__.py +2 -0
- minosse/_version.py +24 -0
- minosse/cli.py +49 -0
- minosse/detector.py +110 -0
- minosse/evidence.py +98 -0
- minosse-0.1.0.dist-info/METADATA +159 -0
- minosse-0.1.0.dist-info/RECORD +11 -0
- minosse-0.1.0.dist-info/WHEEL +4 -0
- minosse-0.1.0.dist-info/entry_points.txt +2 -0
- minosse-0.1.0.dist-info/licenses/LICENSE +21 -0
minosse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .detector import analyze, analyze_path, Result
|
minosse/__main__.py
ADDED
minosse/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
minosse/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
usage: python -m minosse [--proof [DIR]] <image> [image ...]
|
|
4
|
+
|
|
5
|
+
--proof renders a side-by-side evidence sheet per image (original plus
|
|
6
|
+
red heatmap panels for each fired signal) as <name>_proof.png, saved in
|
|
7
|
+
DIR if given, else next to the input image.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
from .detector import analyze
|
|
16
|
+
from .evidence import render
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None):
|
|
20
|
+
args = list(argv if argv is not None else sys.argv[1:])
|
|
21
|
+
proof = False
|
|
22
|
+
proof_dir = None
|
|
23
|
+
if "--proof" in args:
|
|
24
|
+
i = args.index("--proof")
|
|
25
|
+
args.pop(i)
|
|
26
|
+
if i < len(args) and os.path.isdir(args[i]):
|
|
27
|
+
proof_dir = args.pop(i)
|
|
28
|
+
proof = True
|
|
29
|
+
if not args:
|
|
30
|
+
print(__doc__.strip())
|
|
31
|
+
return 2
|
|
32
|
+
for path in args:
|
|
33
|
+
img = Image.open(path)
|
|
34
|
+
r = analyze(img)
|
|
35
|
+
print(f"{path}")
|
|
36
|
+
print(f" signals : {', '.join(r.reasons) if r.reasons else 'none'}")
|
|
37
|
+
print(f" decay={r.decay:.4f} vhf_mf={r.vhf_mf:.3f} "
|
|
38
|
+
f"resid_std={r.resid_std:.4f} flat_noise={r.flat_noise:.4f}")
|
|
39
|
+
print(f" verdict : {r.verdict}")
|
|
40
|
+
if proof:
|
|
41
|
+
base = os.path.splitext(os.path.basename(path))[0] + "_proof.png"
|
|
42
|
+
out = os.path.join(proof_dir or os.path.dirname(path) or ".", base)
|
|
43
|
+
render(img, r).save(out)
|
|
44
|
+
print(f" proof : {out}")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
raise SystemExit(main())
|
minosse/detector.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Minosse — detect AI-generated / AI-edited images.
|
|
2
|
+
|
|
3
|
+
Three complementary signals, each targeting a different artifact class:
|
|
4
|
+
|
|
5
|
+
ringing
|
|
6
|
+
Diffusion VAE decoders leave oscillating ripple halos around
|
|
7
|
+
high-contrast strokes (text, UI borders). Measured as a radial
|
|
8
|
+
profile of band-passed energy vs. distance from strong edges:
|
|
9
|
+
real edges decay fast, ringing persists 10-40 px out.
|
|
10
|
+
|
|
11
|
+
noisy-flats
|
|
12
|
+
AI images (and AI "film grain") carry noise in regions that should
|
|
13
|
+
be perfectly flat — solid UI panels, plain backgrounds. Real
|
|
14
|
+
screenshots have truly flat flats, and JPEG smooths photo flats.
|
|
15
|
+
|
|
16
|
+
synthetic-noise
|
|
17
|
+
Generated/upsampled noise dies out before the Nyquist frequency,
|
|
18
|
+
while real sensor noise stays hot at the highest frequencies. An
|
|
19
|
+
image with strong overall residual noise but a cold top ring of the
|
|
20
|
+
spectrum — and noise present even in flat regions — is synthetic.
|
|
21
|
+
|
|
22
|
+
Thresholds were tuned on data/clean (65 real photos + screenshots) and
|
|
23
|
+
data/dirty (5 AI-generated/edited): 5/5 detected, 0/65 false positives.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from PIL import Image
|
|
30
|
+
from scipy import ndimage
|
|
31
|
+
|
|
32
|
+
_DIST_BINS = [(3, 6), (6, 10), (10, 15), (15, 25), (25, 40), (40, 60), (60, 120)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Result:
|
|
37
|
+
decay: float # halo persistence: mid-distance / near-edge energy
|
|
38
|
+
halo_vs_bg: float # mid-distance energy vs image's own background
|
|
39
|
+
vhf_mf: float # spectral energy at Nyquist ring vs mid frequencies
|
|
40
|
+
resid_std: float # overall noise-residual strength
|
|
41
|
+
flat_noise: float # RMS residual inside coarse-flat regions
|
|
42
|
+
reasons: list = field(default_factory=list)
|
|
43
|
+
score: float = 0.0
|
|
44
|
+
verdict: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _features(img: Image.Image) -> dict:
|
|
48
|
+
img = img.convert("RGB")
|
|
49
|
+
if max(img.size) > 1600:
|
|
50
|
+
s = 1600 / max(img.size)
|
|
51
|
+
img = img.resize((int(img.width * s), int(img.height * s)), Image.LANCZOS)
|
|
52
|
+
a = np.asarray(img, dtype=np.float64) / 255.0
|
|
53
|
+
g = a.mean(2)
|
|
54
|
+
|
|
55
|
+
# --- ringing: band energy vs distance from strong edges
|
|
56
|
+
grad = np.hypot(ndimage.sobel(g, axis=1), ndimage.sobel(g, axis=0))
|
|
57
|
+
edge = grad > np.quantile(grad, 0.97)
|
|
58
|
+
band = ndimage.gaussian_filter(g, 0.8) - ndimage.gaussian_filter(g, 3.0)
|
|
59
|
+
energy = ndimage.uniform_filter(band * band, size=5)
|
|
60
|
+
dist = ndimage.distance_transform_edt(~edge)
|
|
61
|
+
prof = []
|
|
62
|
+
for d0, d1 in _DIST_BINS:
|
|
63
|
+
m = (dist >= d0) & (dist < d1)
|
|
64
|
+
prof.append(float(np.median(energy[m])) if m.any() else 0.0)
|
|
65
|
+
p = np.array(prof) + 1e-12
|
|
66
|
+
decay = float(np.median(p[2:5]) / p[0])
|
|
67
|
+
halo_vs_bg = float(np.median(p[2:5]) / np.median(p[5:7]))
|
|
68
|
+
|
|
69
|
+
# --- spectral shape of the noise residual (center crop, windowed FFT)
|
|
70
|
+
h, w = g.shape
|
|
71
|
+
s2 = min(512, h, w)
|
|
72
|
+
y0, x0 = (h - s2) // 2, (w - s2) // 2
|
|
73
|
+
gc = g[y0:y0 + s2, x0:x0 + s2]
|
|
74
|
+
res_c = gc - ndimage.median_filter(gc, 3)
|
|
75
|
+
win = np.hanning(s2)
|
|
76
|
+
F = np.abs(np.fft.fftshift(np.fft.fft2(res_c * win[:, None] * win[None, :]))) ** 2
|
|
77
|
+
yy, xx = np.indices(F.shape)
|
|
78
|
+
r = np.hypot(yy - s2 / 2, xx - s2 / 2) / (s2 / 2)
|
|
79
|
+
ring = lambda a_, b_: F[(r >= a_) & (r < b_)].mean()
|
|
80
|
+
vhf_mf = float(ring(0.95, 1.2) / (ring(0.25, 0.5) + 1e-20))
|
|
81
|
+
resid_std = float(np.std(res_c))
|
|
82
|
+
|
|
83
|
+
# --- noise inside coarse-flat regions
|
|
84
|
+
coarse = ndimage.gaussian_filter(g, 4)
|
|
85
|
+
cgrad = np.hypot(ndimage.sobel(coarse, axis=1), ndimage.sobel(coarse, axis=0))
|
|
86
|
+
flat = cgrad <= np.quantile(cgrad, 0.20)
|
|
87
|
+
res = g - ndimage.median_filter(g, 3)
|
|
88
|
+
flat_noise = float(np.sqrt(np.mean(res[flat] ** 2)))
|
|
89
|
+
|
|
90
|
+
return dict(decay=decay, halo_vs_bg=halo_vs_bg, vhf_mf=vhf_mf,
|
|
91
|
+
resid_std=resid_std, flat_noise=flat_noise)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def analyze(img: Image.Image) -> Result:
|
|
95
|
+
f = _features(img)
|
|
96
|
+
reasons = []
|
|
97
|
+
if f["vhf_mf"] < 0.22 and f["decay"] > 0.065:
|
|
98
|
+
reasons.append("ringing")
|
|
99
|
+
if f["flat_noise"] > 0.015:
|
|
100
|
+
reasons.append("noisy-flats")
|
|
101
|
+
if f["vhf_mf"] < 0.09 and f["resid_std"] > 0.02 and f["flat_noise"] > 0.001:
|
|
102
|
+
reasons.append("synthetic-noise")
|
|
103
|
+
|
|
104
|
+
score = min(1.0, 0.75 * len(reasons)) if reasons else 0.0
|
|
105
|
+
verdict = "likely AI-generated/edited" if reasons else "likely clean"
|
|
106
|
+
return Result(**f, reasons=reasons, score=score, verdict=verdict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def analyze_path(path: str) -> Result:
|
|
110
|
+
return analyze(Image.open(path))
|
minosse/evidence.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Render visual proof sheets: original image next to panels that
|
|
2
|
+
highlight, in red, the spatial evidence behind each fired signal.
|
|
3
|
+
|
|
4
|
+
- ringing : band-passed oscillation energy in the halo zone
|
|
5
|
+
(3-40 px from strong edges, off-stroke)
|
|
6
|
+
- noisy-flats : noise residual energy inside coarse-flat regions
|
|
7
|
+
- synthetic-noise: amplified full-frame noise residual (its spatial
|
|
8
|
+
texture is the evidence; spectrum stats in caption)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from PIL import Image, ImageDraw
|
|
13
|
+
from scipy import ndimage
|
|
14
|
+
|
|
15
|
+
from .detector import Result, _features # noqa: F401 (shared pipeline)
|
|
16
|
+
|
|
17
|
+
_PANEL_W = 640
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _prep(img: Image.Image):
|
|
21
|
+
img = img.convert("RGB")
|
|
22
|
+
if max(img.size) > 1600:
|
|
23
|
+
s = 1600 / max(img.size)
|
|
24
|
+
img = img.resize((int(img.width * s), int(img.height * s)), Image.LANCZOS)
|
|
25
|
+
g = np.asarray(img, dtype=np.float64) / 255.0
|
|
26
|
+
return img, g.mean(2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _heat_overlay(base: Image.Image, heat: np.ndarray, gain: float = 1.0) -> Image.Image:
|
|
30
|
+
"""Dim grayscale base with the heat map burned in as red."""
|
|
31
|
+
h = np.clip(heat * gain, 0, 1)
|
|
32
|
+
gray = np.asarray(base.convert("L"), dtype=np.float64) / 255.0 * 0.35
|
|
33
|
+
rgb = np.stack([gray + h * 0.65, gray + h * 0.10, gray], axis=-1)
|
|
34
|
+
return Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _abs_norm(x: np.ndarray, full_scale: float) -> np.ndarray:
|
|
38
|
+
"""Absolute scaling: `full_scale` maps to 1.0. Calibrated to the
|
|
39
|
+
detector thresholds so clean images render dark instead of being
|
|
40
|
+
stretched to look hot by per-image normalization."""
|
|
41
|
+
return np.clip(np.sqrt(np.clip(x, 0, None)) / full_scale, 0, 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _maps(g: np.ndarray) -> dict:
|
|
45
|
+
grad = np.hypot(ndimage.sobel(g, axis=1), ndimage.sobel(g, axis=0))
|
|
46
|
+
edge = grad > np.quantile(grad, 0.97)
|
|
47
|
+
band = ndimage.gaussian_filter(g, 0.8) - ndimage.gaussian_filter(g, 3.0)
|
|
48
|
+
energy = ndimage.uniform_filter(band * band, size=5)
|
|
49
|
+
dist = ndimage.distance_transform_edt(~edge)
|
|
50
|
+
|
|
51
|
+
coarse = ndimage.gaussian_filter(g, 4)
|
|
52
|
+
cgrad = np.hypot(ndimage.sobel(coarse, axis=1), ndimage.sobel(coarse, axis=0))
|
|
53
|
+
flat = cgrad <= np.quantile(cgrad, 0.20)
|
|
54
|
+
res = g - ndimage.median_filter(g, 3)
|
|
55
|
+
|
|
56
|
+
# Ringing evidence = oscillation where the image should be smooth:
|
|
57
|
+
# 8-40 px from a strong edge (past the 1-2 px overshoot every real
|
|
58
|
+
# edge has) AND locally flat at coarse scale, so legitimate texture
|
|
59
|
+
# (fabric, foliage, carpet) is excluded.
|
|
60
|
+
smooth = cgrad <= np.quantile(cgrad, 0.60)
|
|
61
|
+
halo = (dist >= 8) & (dist < 40) & smooth
|
|
62
|
+
ringing = np.where(halo, energy, 0.0)
|
|
63
|
+
flat_noise = np.where(flat, ndimage.uniform_filter(res * res, size=9), 0.0)
|
|
64
|
+
|
|
65
|
+
# full-scale values sit a bit above the detector's firing thresholds
|
|
66
|
+
return {
|
|
67
|
+
"ringing": _abs_norm(ringing, 0.007),
|
|
68
|
+
"noisy-flats": _abs_norm(flat_noise, 0.03),
|
|
69
|
+
"synthetic-noise": np.clip(np.abs(res) * 12, 0, 1),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render(img: Image.Image, result: Result) -> Image.Image:
|
|
74
|
+
"""Build a proof sheet: original + one evidence panel per fired
|
|
75
|
+
signal (all three panels if nothing fired, for comparison)."""
|
|
76
|
+
base, g = _prep(img)
|
|
77
|
+
maps = _maps(g)
|
|
78
|
+
signals = result.reasons if result.reasons else list(maps)
|
|
79
|
+
|
|
80
|
+
scale = _PANEL_W / base.width
|
|
81
|
+
size = (_PANEL_W, int(base.height * scale))
|
|
82
|
+
panels = [("original", base.resize(size, Image.LANCZOS))]
|
|
83
|
+
for name in signals:
|
|
84
|
+
overlay = _heat_overlay(base, maps[name])
|
|
85
|
+
panels.append((name, overlay.resize(size, Image.LANCZOS)))
|
|
86
|
+
|
|
87
|
+
cap_h = 26
|
|
88
|
+
sheet = Image.new("RGB", (_PANEL_W * len(panels), size[1] + cap_h), (12, 12, 12))
|
|
89
|
+
draw = ImageDraw.Draw(sheet)
|
|
90
|
+
for i, (name, panel) in enumerate(panels):
|
|
91
|
+
sheet.paste(panel, (i * _PANEL_W, cap_h))
|
|
92
|
+
label = name if name == "original" else (
|
|
93
|
+
f"{name} {'[FIRED]' if name in result.reasons else '(not fired)'}")
|
|
94
|
+
draw.text((i * _PANEL_W + 8, 6), label,
|
|
95
|
+
fill=(255, 80, 80) if name in result.reasons else (200, 200, 200))
|
|
96
|
+
draw.text((sheet.width - 320, 6), f"verdict: {result.verdict}",
|
|
97
|
+
fill=(255, 80, 80) if result.reasons else (120, 220, 120))
|
|
98
|
+
return sheet
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minosse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detects AI-generated or AI-edited images using signal-processing artifact analysis — no ML model.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cesp99/Minosse
|
|
6
|
+
Project-URL: Issues, https://github.com/cesp99/Minosse/issues
|
|
7
|
+
Author: cesp99
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-detection,diffusion,image-forensics,signal-processing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: numpy
|
|
26
|
+
Requires-Dist: pillow
|
|
27
|
+
Requires-Dist: scipy
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Minosse
|
|
31
|
+
|
|
32
|
+
Detects AI-generated or AI-edited images using three complementary,
|
|
33
|
+
hand-crafted artifact signals — **no ML model, just signal processing.**
|
|
34
|
+
|
|
35
|
+
Minosse looks for tell-tale traces that diffusion models and neural
|
|
36
|
+
upsamplers leave behind: ringing halos around text, noise in flat
|
|
37
|
+
regions that shouldn't have any, and a synthetic signature in the
|
|
38
|
+
high-frequency spectrum.
|
|
39
|
+
|
|
40
|
+
## Signals
|
|
41
|
+
|
|
42
|
+
| Signal | What it catches | How it works |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| **ringing** | Diffusion VAE decoder halos | Radial profile of band-pass energy vs. distance from strong edges. Real edges decay fast; ringing persists 10–40 px out. |
|
|
45
|
+
| **noisy-flats** | AI "film grain" on solid panels | RMS residual noise inside coarse-flat regions. Real screenshots are truly flat; JPEG smooths photo flats. |
|
|
46
|
+
| **synthetic-noise** | Upsampled / generated noise | Spectral energy at the Nyquist ring vs. mid frequencies. Real sensor noise stays hot at the highest frequencies; synthetic noise dies off. |
|
|
47
|
+
|
|
48
|
+
An image is flagged if **any** signal fires; the fired signals are reported
|
|
49
|
+
along with the raw feature values.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
.venv/bin/pip install -r requirements.txt
|
|
57
|
+
|
|
58
|
+
# Analyse a single image
|
|
59
|
+
.venv/bin/python -m minosse path/to/image.png
|
|
60
|
+
|
|
61
|
+
# Analyse multiple images with visual proof sheets
|
|
62
|
+
.venv/bin/python -m minosse --proof results/ image1.png image2.jpg
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `--proof` output
|
|
66
|
+
|
|
67
|
+
The `--proof` flag writes `<name>_proof.png`: a side-by-side sheet with
|
|
68
|
+
the original image next to one panel per fired signal, with the artifact
|
|
69
|
+
evidence burned in as a **red heatmap**.
|
|
70
|
+
|
|
71
|
+
Heat is scaled absolutely (calibrated to the detection thresholds), so
|
|
72
|
+
clean images render dark — the glow itself is the proof. For ringing,
|
|
73
|
+
the map only shows oscillation in regions that *should* be smooth (8–40 px
|
|
74
|
+
from strong edges, locally flat at coarse scale), so legitimate texture
|
|
75
|
+
doesn't light up.
|
|
76
|
+
|
|
77
|
+
## Python API
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from minosse import analyze, analyze_path, Result
|
|
81
|
+
|
|
82
|
+
# From a file path
|
|
83
|
+
r = analyze_path("image.png")
|
|
84
|
+
print(r.verdict) # "likely AI-generated/edited" or "likely clean"
|
|
85
|
+
print(r.reasons) # e.g. ["ringing", "synthetic-noise"]
|
|
86
|
+
print(r.decay) # ringing feature value
|
|
87
|
+
print(r.vhf_mf) # spectral ratio feature value
|
|
88
|
+
print(r.flat_noise) # flat-region noise feature value
|
|
89
|
+
print(r.resid_std) # overall residual noise
|
|
90
|
+
|
|
91
|
+
# From a PIL Image
|
|
92
|
+
from PIL import Image
|
|
93
|
+
img = Image.open("image.png")
|
|
94
|
+
r = analyze(img)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `Result` fields
|
|
98
|
+
|
|
99
|
+
| Field | Type | Description |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `verdict` | `str` | `"likely AI-generated/edited"` or `"likely clean"` |
|
|
102
|
+
| `reasons` | `list[str]` | Fired signal names: `ringing`, `noisy-flats`, `synthetic-noise` |
|
|
103
|
+
| `score` | `float` | Confidence score `[0, 1]` (0.75 per firing signal, capped at 1.0) |
|
|
104
|
+
| `decay` | `float` | Ringing persistence: mid-distance / near-edge band energy |
|
|
105
|
+
| `halo_vs_bg` | `float` | Mid-distance energy vs. image background |
|
|
106
|
+
| `vhf_mf` | `float` | Spectral ratio: Nyquist ring / mid frequencies |
|
|
107
|
+
| `resid_std` | `float` | Standard deviation of the noise residual |
|
|
108
|
+
| `flat_noise` | `float` | RMS residual inside coarse-flat regions |
|
|
109
|
+
|
|
110
|
+
### Visual proof from Python
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from minosse import analyze, evidence
|
|
114
|
+
from PIL import Image
|
|
115
|
+
|
|
116
|
+
img = Image.open("image.png")
|
|
117
|
+
r = analyze(img)
|
|
118
|
+
sheet = evidence.render(img, r)
|
|
119
|
+
sheet.save("proof.png")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Project structure
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
minosse/
|
|
126
|
+
├── minosse/
|
|
127
|
+
│ ├── __init__.py # Public API: analyze, analyze_path, Result
|
|
128
|
+
│ ├── __main__.py # python -m minosse entry point
|
|
129
|
+
│ ├── cli.py # Argument parsing and CLI loop
|
|
130
|
+
│ ├── detector.py # Feature extraction + threshold logic
|
|
131
|
+
│ └── evidence.py # Visual proof sheet renderer
|
|
132
|
+
├── results/ # Default --proof output directory
|
|
133
|
+
├── requirements.txt # numpy, pillow, scipy
|
|
134
|
+
└── README.md
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## How it works (briefly)
|
|
138
|
+
|
|
139
|
+
1. **Preprocessing** — the image is converted to RGB and downscaled if
|
|
140
|
+
its longest edge exceeds 1600 px.
|
|
141
|
+
2. **Feature extraction** (`detector._features()`) — three independent
|
|
142
|
+
signal-processing pipelines compute scalar features from the
|
|
143
|
+
grayscale luminance.
|
|
144
|
+
3. **Thresholding** — each feature is compared against a tuned threshold;
|
|
145
|
+
if it exceeds the threshold the corresponding signal is flagged.
|
|
146
|
+
4. **Verdict** — if any signal fires, the image is marked likely
|
|
147
|
+
AI-generated/edited; otherwise it's likely clean.
|
|
148
|
+
|
|
149
|
+
For a deeper explanation of each signal, see [docs/signals.md](docs/signals.md).
|
|
150
|
+
|
|
151
|
+
## Caveats
|
|
152
|
+
|
|
153
|
+
The thresholds are calibrated on a small set of examples and should be
|
|
154
|
+
re-validated as more AI samples are added. See [docs/tuning.md](docs/tuning.md)
|
|
155
|
+
for instructions on re-tuning.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
minosse/__init__.py,sha256=2QKb-KV8pdF7Q1C2I0xsoXBUTZKE8PwNODsbwee-nxk,52
|
|
2
|
+
minosse/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
|
|
3
|
+
minosse/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
4
|
+
minosse/cli.py,sha256=JPoUPqq0fPmS2TKH2Rvf4Puu7uOfAv-frDDfrl1NNsM,1450
|
|
5
|
+
minosse/detector.py,sha256=rvud6lZ3kYKeL3bggDY-P3aug0591w1RnIpiProLcNA,4353
|
|
6
|
+
minosse/evidence.py,sha256=ByLLSjyjmJXaZyL_gkU-KS56HKIWcuilGKcvtNVC4Vc,4175
|
|
7
|
+
minosse-0.1.0.dist-info/METADATA,sha256=10LoUd2C_LQCD9Yn3IvakAyAUeEKlpAXFjtEjudY6jI,5919
|
|
8
|
+
minosse-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
minosse-0.1.0.dist-info/entry_points.txt,sha256=hqd_YmuzOB1tOABMTB-C1ijKkjdgrTp0PbOY20xWumM,45
|
|
10
|
+
minosse-0.1.0.dist-info/licenses/LICENSE,sha256=RnUiRM7llD6-Twf5z-GgfFyZ-XrR_IBEq2Ryrlvp8ZM,1071
|
|
11
|
+
minosse-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlo Esposito
|
|
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.
|