minosse 0.1.0__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.
- minosse-0.1.0/.gitignore +8 -0
- minosse-0.1.0/LICENSE +21 -0
- minosse-0.1.0/PKG-INFO +159 -0
- minosse-0.1.0/README.md +130 -0
- minosse-0.1.0/docs/signals.md +171 -0
- minosse-0.1.0/docs/tuning.md +119 -0
- minosse-0.1.0/minosse/__init__.py +1 -0
- minosse-0.1.0/minosse/__main__.py +2 -0
- minosse-0.1.0/minosse/_version.py +24 -0
- minosse-0.1.0/minosse/cli.py +49 -0
- minosse-0.1.0/minosse/detector.py +110 -0
- minosse-0.1.0/minosse/evidence.py +98 -0
- minosse-0.1.0/pyproject.toml +59 -0
- minosse-0.1.0/requirements.txt +3 -0
minosse-0.1.0/.gitignore
ADDED
minosse-0.1.0/LICENSE
ADDED
|
@@ -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.
|
minosse-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
minosse-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Minosse
|
|
2
|
+
|
|
3
|
+
Detects AI-generated or AI-edited images using three complementary,
|
|
4
|
+
hand-crafted artifact signals — **no ML model, just signal processing.**
|
|
5
|
+
|
|
6
|
+
Minosse looks for tell-tale traces that diffusion models and neural
|
|
7
|
+
upsamplers leave behind: ringing halos around text, noise in flat
|
|
8
|
+
regions that shouldn't have any, and a synthetic signature in the
|
|
9
|
+
high-frequency spectrum.
|
|
10
|
+
|
|
11
|
+
## Signals
|
|
12
|
+
|
|
13
|
+
| Signal | What it catches | How it works |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| **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. |
|
|
16
|
+
| **noisy-flats** | AI "film grain" on solid panels | RMS residual noise inside coarse-flat regions. Real screenshots are truly flat; JPEG smooths photo flats. |
|
|
17
|
+
| **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. |
|
|
18
|
+
|
|
19
|
+
An image is flagged if **any** signal fires; the fired signals are reported
|
|
20
|
+
along with the raw feature values.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m venv .venv
|
|
27
|
+
.venv/bin/pip install -r requirements.txt
|
|
28
|
+
|
|
29
|
+
# Analyse a single image
|
|
30
|
+
.venv/bin/python -m minosse path/to/image.png
|
|
31
|
+
|
|
32
|
+
# Analyse multiple images with visual proof sheets
|
|
33
|
+
.venv/bin/python -m minosse --proof results/ image1.png image2.jpg
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `--proof` output
|
|
37
|
+
|
|
38
|
+
The `--proof` flag writes `<name>_proof.png`: a side-by-side sheet with
|
|
39
|
+
the original image next to one panel per fired signal, with the artifact
|
|
40
|
+
evidence burned in as a **red heatmap**.
|
|
41
|
+
|
|
42
|
+
Heat is scaled absolutely (calibrated to the detection thresholds), so
|
|
43
|
+
clean images render dark — the glow itself is the proof. For ringing,
|
|
44
|
+
the map only shows oscillation in regions that *should* be smooth (8–40 px
|
|
45
|
+
from strong edges, locally flat at coarse scale), so legitimate texture
|
|
46
|
+
doesn't light up.
|
|
47
|
+
|
|
48
|
+
## Python API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from minosse import analyze, analyze_path, Result
|
|
52
|
+
|
|
53
|
+
# From a file path
|
|
54
|
+
r = analyze_path("image.png")
|
|
55
|
+
print(r.verdict) # "likely AI-generated/edited" or "likely clean"
|
|
56
|
+
print(r.reasons) # e.g. ["ringing", "synthetic-noise"]
|
|
57
|
+
print(r.decay) # ringing feature value
|
|
58
|
+
print(r.vhf_mf) # spectral ratio feature value
|
|
59
|
+
print(r.flat_noise) # flat-region noise feature value
|
|
60
|
+
print(r.resid_std) # overall residual noise
|
|
61
|
+
|
|
62
|
+
# From a PIL Image
|
|
63
|
+
from PIL import Image
|
|
64
|
+
img = Image.open("image.png")
|
|
65
|
+
r = analyze(img)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `Result` fields
|
|
69
|
+
|
|
70
|
+
| Field | Type | Description |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `verdict` | `str` | `"likely AI-generated/edited"` or `"likely clean"` |
|
|
73
|
+
| `reasons` | `list[str]` | Fired signal names: `ringing`, `noisy-flats`, `synthetic-noise` |
|
|
74
|
+
| `score` | `float` | Confidence score `[0, 1]` (0.75 per firing signal, capped at 1.0) |
|
|
75
|
+
| `decay` | `float` | Ringing persistence: mid-distance / near-edge band energy |
|
|
76
|
+
| `halo_vs_bg` | `float` | Mid-distance energy vs. image background |
|
|
77
|
+
| `vhf_mf` | `float` | Spectral ratio: Nyquist ring / mid frequencies |
|
|
78
|
+
| `resid_std` | `float` | Standard deviation of the noise residual |
|
|
79
|
+
| `flat_noise` | `float` | RMS residual inside coarse-flat regions |
|
|
80
|
+
|
|
81
|
+
### Visual proof from Python
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from minosse import analyze, evidence
|
|
85
|
+
from PIL import Image
|
|
86
|
+
|
|
87
|
+
img = Image.open("image.png")
|
|
88
|
+
r = analyze(img)
|
|
89
|
+
sheet = evidence.render(img, r)
|
|
90
|
+
sheet.save("proof.png")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Project structure
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
minosse/
|
|
97
|
+
├── minosse/
|
|
98
|
+
│ ├── __init__.py # Public API: analyze, analyze_path, Result
|
|
99
|
+
│ ├── __main__.py # python -m minosse entry point
|
|
100
|
+
│ ├── cli.py # Argument parsing and CLI loop
|
|
101
|
+
│ ├── detector.py # Feature extraction + threshold logic
|
|
102
|
+
│ └── evidence.py # Visual proof sheet renderer
|
|
103
|
+
├── results/ # Default --proof output directory
|
|
104
|
+
├── requirements.txt # numpy, pillow, scipy
|
|
105
|
+
└── README.md
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How it works (briefly)
|
|
109
|
+
|
|
110
|
+
1. **Preprocessing** — the image is converted to RGB and downscaled if
|
|
111
|
+
its longest edge exceeds 1600 px.
|
|
112
|
+
2. **Feature extraction** (`detector._features()`) — three independent
|
|
113
|
+
signal-processing pipelines compute scalar features from the
|
|
114
|
+
grayscale luminance.
|
|
115
|
+
3. **Thresholding** — each feature is compared against a tuned threshold;
|
|
116
|
+
if it exceeds the threshold the corresponding signal is flagged.
|
|
117
|
+
4. **Verdict** — if any signal fires, the image is marked likely
|
|
118
|
+
AI-generated/edited; otherwise it's likely clean.
|
|
119
|
+
|
|
120
|
+
For a deeper explanation of each signal, see [docs/signals.md](docs/signals.md).
|
|
121
|
+
|
|
122
|
+
## Caveats
|
|
123
|
+
|
|
124
|
+
The thresholds are calibrated on a small set of examples and should be
|
|
125
|
+
re-validated as more AI samples are added. See [docs/tuning.md](docs/tuning.md)
|
|
126
|
+
for instructions on re-tuning.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Signal deep-dive
|
|
2
|
+
|
|
3
|
+
Minosse uses three independent signal-processing pipelines to detect
|
|
4
|
+
AI-generated or AI-edited images. Each targets a different class of
|
|
5
|
+
artifact that current diffusion models and neural upsamplers leave
|
|
6
|
+
behind.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Ringing
|
|
11
|
+
|
|
12
|
+
### What it catches
|
|
13
|
+
|
|
14
|
+
Diffusion VAE decoders — particularly those based on convolutional
|
|
15
|
+
architectures — produce oscillating ripple halos around high-contrast
|
|
16
|
+
strokes such as text, UI borders, and sharp edges. This is a known
|
|
17
|
+
side effect of the upsampling layers in the decoder, which act like
|
|
18
|
+
imperfect deconvolution filters.
|
|
19
|
+
|
|
20
|
+
### How it works
|
|
21
|
+
|
|
22
|
+
1. **Edge detection** — a Sobel gradient magnitude is computed on the
|
|
23
|
+
grayscale image. Strong edges are identified by thresholding at the
|
|
24
|
+
97th percentile.
|
|
25
|
+
|
|
26
|
+
2. **Band-pass energy** — a band-pass filter (difference of Gaussians
|
|
27
|
+
with σ = 0.8 and σ = 3.0) extracts oscillation energy in the
|
|
28
|
+
spatial frequency range that typical ringing occupies.
|
|
29
|
+
|
|
30
|
+
3. **Radial profile** — a distance transform from the strong-edge mask
|
|
31
|
+
yields a map of "how far from the nearest edge". The band-pass
|
|
32
|
+
energy is median-aggregated into distance bins:
|
|
33
|
+
|
|
34
|
+
| Bin (px) | Interpretation |
|
|
35
|
+
|---|---|
|
|
36
|
+
| 3–6 | Near edge (includes legitimate edge overshoot) |
|
|
37
|
+
| 6–10 | Transition zone |
|
|
38
|
+
| 10–15 | **Halo zone** |
|
|
39
|
+
| 15–25 | **Halo zone** |
|
|
40
|
+
| 25–40 | **Halo zone** |
|
|
41
|
+
| 40–60 | Background |
|
|
42
|
+
| 60–120 | Background |
|
|
43
|
+
|
|
44
|
+
4. **Feature: `decay`** — the ratio of median energy in the halo zone
|
|
45
|
+
(bins 10–25 px) to the near-edge bin (3–6 px). Real edges decay
|
|
46
|
+
quickly; ringing sustains energy at a distance.
|
|
47
|
+
|
|
48
|
+
5. **Feature: `halo_vs_bg`** — halo-zone energy vs. background energy
|
|
49
|
+
(40–120 px). Helps distinguish ringing from legitimate texture.
|
|
50
|
+
|
|
51
|
+
### Threshold
|
|
52
|
+
|
|
53
|
+
`vhf_mf < 0.22 AND decay > 0.065`
|
|
54
|
+
|
|
55
|
+
(Combined with a spectral check to avoid false positives on strongly
|
|
56
|
+
textured natural images.)
|
|
57
|
+
|
|
58
|
+
### Visual proof
|
|
59
|
+
|
|
60
|
+
The ringing heatmap shows band-pass oscillation energy *only* in regions
|
|
61
|
+
that are 8–40 px from a strong edge *and* locally flat at coarse scale
|
|
62
|
+
(coarse gradient ≤ 60th percentile). This ensures that legitimate
|
|
63
|
+
texture (fabric, foliage, carpet) does not light up.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Noisy-flats
|
|
68
|
+
|
|
69
|
+
### What it catches
|
|
70
|
+
|
|
71
|
+
AI-generated images — especially those that apply a "film grain" effect
|
|
72
|
+
for realism — carry noise in regions that should be perfectly flat:
|
|
73
|
+
solid UI panels, plain backgrounds, or uniform colour swatches.
|
|
74
|
+
|
|
75
|
+
Real screenshots have truly flat flats (the pixel values are constant).
|
|
76
|
+
Real photos have flat regions that are smoothed by JPEG compression.
|
|
77
|
+
AI noise is spatially structured and survives in flat areas.
|
|
78
|
+
|
|
79
|
+
### How it works
|
|
80
|
+
|
|
81
|
+
1. **Coarse-flat detection** — a Gaussian blur (σ = 4) removes fine
|
|
82
|
+
detail, then a Sobel gradient identifies regions of low variation.
|
|
83
|
+
The flattest 20% of the image (by coarse gradient) are selected.
|
|
84
|
+
|
|
85
|
+
2. **Noise residual** — a 3×3 median filter estimates the clean image;
|
|
86
|
+
the residual is the difference between the original and the filtered
|
|
87
|
+
version.
|
|
88
|
+
|
|
89
|
+
3. **Feature: `flat_noise`** — the RMS of the residual inside the
|
|
90
|
+
coarse-flat regions.
|
|
91
|
+
|
|
92
|
+
### Threshold
|
|
93
|
+
|
|
94
|
+
`flat_noise > 0.015`
|
|
95
|
+
|
|
96
|
+
### Visual proof
|
|
97
|
+
|
|
98
|
+
The noisy-flats heatmap shows the squared noise residual (blurred with
|
|
99
|
+
a 9×9 uniform filter) inside the flat regions. The heat is scaled
|
|
100
|
+
absolutely so that clean images (where `flat_noise` sits well below
|
|
101
|
+
the threshold) render dark.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Synthetic-noise
|
|
106
|
+
|
|
107
|
+
### What it catches
|
|
108
|
+
|
|
109
|
+
Real camera sensor noise has a characteristic spectral signature: it
|
|
110
|
+
remains energetic right up to the Nyquist frequency (the highest
|
|
111
|
+
spatial frequencies the sensor can resolve). Generated or upsampled
|
|
112
|
+
noise — whether from a diffusion model's latent space or a neural
|
|
113
|
+
upsampler — dies off before Nyquist, because the model never learned
|
|
114
|
+
to produce true pixel-level randomness.
|
|
115
|
+
|
|
116
|
+
### How it works
|
|
117
|
+
|
|
118
|
+
1. **Center crop** — a square centre crop (up to 512×512) is taken to
|
|
119
|
+
avoid edge artefacts.
|
|
120
|
+
|
|
121
|
+
2. **Noise residual** — a 3×3 median filter isolates the high-frequency
|
|
122
|
+
noise.
|
|
123
|
+
|
|
124
|
+
3. **Windowing & FFT** — a Hann window is applied to reduce spectral
|
|
125
|
+
leakage, then the 2D power spectrum is computed.
|
|
126
|
+
|
|
127
|
+
4. **Radial rings** — the spectrum is averaged over two annular regions:
|
|
128
|
+
|
|
129
|
+
| Ring | Radius | Interpretation |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Mid frequencies (MF) | 0.25–0.50 of Nyquist | Typical image energy |
|
|
132
|
+
| Very high frequencies (VHF) | 0.95–1.20 of Nyquist | Near-Nyquist noise |
|
|
133
|
+
|
|
134
|
+
5. **Feature: `vhf_mf`** — the ratio VHF / MF energy. Real sensor noise
|
|
135
|
+
keeps this ratio relatively high; synthetic noise shows a sharp drop.
|
|
136
|
+
|
|
137
|
+
6. **Feature: `resid_std`** — the standard deviation of the noise
|
|
138
|
+
residual across the whole centre crop. Measures overall noise level.
|
|
139
|
+
|
|
140
|
+
### Threshold
|
|
141
|
+
|
|
142
|
+
`vhf_mf < 0.09 AND resid_std > 0.02 AND flat_noise > 0.001`
|
|
143
|
+
|
|
144
|
+
The `flat_noise` condition prevents false positives on clean images that
|
|
145
|
+
happen to have a cold spectrum (e.g. heavily compressed JPEGs).
|
|
146
|
+
|
|
147
|
+
### Visual proof
|
|
148
|
+
|
|
149
|
+
The synthetic-noise heatmap is simply the absolute noise residual
|
|
150
|
+
(amplified by 12×). It looks like a noise texture — the evidence is
|
|
151
|
+
that the texture exists at all in an image that also has a cold
|
|
152
|
+
high-frequency spectrum.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Why no ML model
|
|
157
|
+
|
|
158
|
+
Minosse is deliberately **model-free** for several reasons:
|
|
159
|
+
|
|
160
|
+
- **Interpretability** — every decision is traceable to a specific
|
|
161
|
+
physical feature. You can see *why* an image was flagged.
|
|
162
|
+
- **No training data contamination** — no risk of overfitting to a
|
|
163
|
+
particular generator or dataset.
|
|
164
|
+
- **No model drift** — the thresholds may need adjustment, but the
|
|
165
|
+
signal-processing pipeline is stable and deterministic.
|
|
166
|
+
- **Speed** — a single image analyses in under a second on a CPU.
|
|
167
|
+
- **Lightweight** — no GPU, no large model weights, no PyTorch/TensorFlow.
|
|
168
|
+
|
|
169
|
+
The trade-off is that Minosse will not catch AI images that lack these
|
|
170
|
+
specific artifacts. As generators improve, the signals may need to be
|
|
171
|
+
augmented or re-tuned.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Tuning guide
|
|
2
|
+
|
|
3
|
+
Minosse's detection thresholds are hand-tuned on a small set of examples.
|
|
4
|
+
As you add more AI-generated images you should re-validate — and possibly
|
|
5
|
+
re-tune — the thresholds.
|
|
6
|
+
|
|
7
|
+
## Thresholds (as of initial release)
|
|
8
|
+
|
|
9
|
+
| Signal | Feature | Threshold | Direction |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| Ringing | `vhf_mf < 0.22` AND `decay > 0.065` | — | — |
|
|
12
|
+
| Noisy-flats | `flat_noise > 0.015` | ↑ | Higher → fewer false positives, fewer detections |
|
|
13
|
+
| Synthetic-noise | `vhf_mf < 0.09` AND `resid_std > 0.02` AND `flat_noise > 0.001` | — | — |
|
|
14
|
+
|
|
15
|
+
## Re-tuning workflow
|
|
16
|
+
|
|
17
|
+
### 1. Gather data
|
|
18
|
+
|
|
19
|
+
Add real images to a `clean/` folder and AI-generated/edited images to
|
|
20
|
+
a `dirty/` folder. Supported formats: JPEG, PNG.
|
|
21
|
+
|
|
22
|
+
### 2. Extract features on the whole dataset
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
.venv/bin/python -c "
|
|
26
|
+
from minosse.detector import _features
|
|
27
|
+
from PIL import Image
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
import csv, json
|
|
30
|
+
|
|
31
|
+
rows = []
|
|
32
|
+
for label, folder in [('clean', 'clean'), ('dirty', 'dirty')]:
|
|
33
|
+
for p in sorted(Path(folder).iterdir()):
|
|
34
|
+
if p.suffix.lower() in ('.jpg', '.jpeg', '.png'):
|
|
35
|
+
f = _features(Image.open(p))
|
|
36
|
+
rows.append({'file': p.name, 'label': label, **f})
|
|
37
|
+
|
|
38
|
+
with open('features.csv', 'w') as fp:
|
|
39
|
+
w = csv.DictWriter(fp, fieldnames=rows[0].keys())
|
|
40
|
+
w.writeheader()
|
|
41
|
+
w.writerows(rows)
|
|
42
|
+
print('features.csv written')
|
|
43
|
+
"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Analyse the feature distribution
|
|
47
|
+
|
|
48
|
+
Open `features.csv` in a spreadsheet or use the notebook below to
|
|
49
|
+
plot histograms of each feature, coloured by label. Look for the
|
|
50
|
+
threshold that separates clean from dirty with the widest margin.
|
|
51
|
+
|
|
52
|
+
### 4. Grid search
|
|
53
|
+
|
|
54
|
+
Run a grid search over the three thresholds and pick the combination
|
|
55
|
+
that maximises `detections - false_positives` (or your preferred
|
|
56
|
+
metric):
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import csv
|
|
60
|
+
import itertools
|
|
61
|
+
|
|
62
|
+
rows = list(csv.DictReader(open('features.csv')))
|
|
63
|
+
for r in rows:
|
|
64
|
+
for k in ('decay', 'vhf_mf', 'resid_std', 'flat_noise', 'halo_vs_bg'):
|
|
65
|
+
r[k] = float(r[k])
|
|
66
|
+
|
|
67
|
+
best = (0, None)
|
|
68
|
+
for vhf_mf_r, decay_r, vhf_mf_s, resid_std_s, flat_noise_s in itertools.product(
|
|
69
|
+
[0.18, 0.20, 0.22, 0.24],
|
|
70
|
+
[0.055, 0.060, 0.065, 0.070],
|
|
71
|
+
[0.07, 0.08, 0.09, 0.10],
|
|
72
|
+
[0.015, 0.020, 0.025],
|
|
73
|
+
[0.001, 0.0005],
|
|
74
|
+
):
|
|
75
|
+
tp = fp = 0
|
|
76
|
+
for r in rows:
|
|
77
|
+
ringing = r['vhf_mf'] < vhf_mf_r and r['decay'] > decay_r
|
|
78
|
+
nf = r['flat_noise'] > 0.015 # keep fixed or add to grid
|
|
79
|
+
sn = r['vhf_mf'] < vhf_mf_s and r['resid_std'] > resid_std_s and r['flat_noise'] > flat_noise_s
|
|
80
|
+
fired = ringing or nf or sn
|
|
81
|
+
if fired and r['label'] == 'dirty':
|
|
82
|
+
tp += 1
|
|
83
|
+
if fired and r['label'] == 'clean':
|
|
84
|
+
fp += 1
|
|
85
|
+
score = tp - fp
|
|
86
|
+
if score > best[0]:
|
|
87
|
+
best = (score, (vhf_mf_r, decay_r, vhf_mf_s, resid_std_s, flat_noise_s))
|
|
88
|
+
|
|
89
|
+
print(f"Best score: {best[0]} at {best[1]}")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 5. Update `detector.py`
|
|
93
|
+
|
|
94
|
+
Edit the thresholds in `detector.analyze()` to match the grid search
|
|
95
|
+
results.
|
|
96
|
+
|
|
97
|
+
### 6. Update the results
|
|
98
|
+
|
|
99
|
+
Update the detection results in `README.md` with the new counts.
|
|
100
|
+
|
|
101
|
+
## Adding a new signal
|
|
102
|
+
|
|
103
|
+
If you identify a new artifact class:
|
|
104
|
+
|
|
105
|
+
1. Add a new feature computation in `detector._features()`.
|
|
106
|
+
2. Add a threshold check in `detector.analyze()`.
|
|
107
|
+
3. Add a heatmap entry in `evidence._maps()`.
|
|
108
|
+
4. Add the signal name to the `evidence.render()` label mapping.
|
|
109
|
+
5. Document it in `docs/signals.md`.
|
|
110
|
+
|
|
111
|
+
## Testing changes
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Run on all clean images — should report 0
|
|
115
|
+
.venv/bin/python -m minosse clean/*.jpg clean/*.png
|
|
116
|
+
|
|
117
|
+
# Run on all dirty images — should report all
|
|
118
|
+
.venv/bin/python -m minosse dirty/*
|
|
119
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .detector import analyze, analyze_path, Result
|
|
@@ -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
|
|
@@ -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())
|
|
@@ -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))
|
|
@@ -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,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18", "hatch-vcs>=0.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "minosse"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Detects AI-generated or AI-edited images using signal-processing artifact analysis — no ML model."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "cesp99" }]
|
|
14
|
+
keywords = ["ai-detection", "image-forensics", "diffusion", "signal-processing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"numpy",
|
|
32
|
+
"pillow",
|
|
33
|
+
"scipy",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/cesp99/Minosse"
|
|
38
|
+
Issues = "https://github.com/cesp99/Minosse/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
minosse = "minosse.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
source = "vcs"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.hooks.vcs]
|
|
47
|
+
version-file = "minosse/_version.py"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["minosse"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = [
|
|
54
|
+
"minosse",
|
|
55
|
+
"README.md",
|
|
56
|
+
"requirements.txt",
|
|
57
|
+
"docs",
|
|
58
|
+
"LICENSE",
|
|
59
|
+
]
|