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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ data/
3
+ results/
4
+ minosse/__pycache__/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ minosse/_version.py
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
@@ -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,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ numpy
2
+ pillow
3
+ scipy