Skip to content

Repository files navigation

AdaptiveStego

tests python license platforms

A research bench for image steganography and steganalysis: content-adaptive LSB embedding, minimum-distortion embedding through syndrome-trellis coding, five baseline methods to compare them against, quality metrics, twelve attacks on the container, classical detectors, and a desktop interface in English, Ukrainian and Russian.

The hypothesis this project exists to test. Choosing which samples and which colour channels carry the message according to the local textural complexity of the image lowers the detectability of the message at a comparable capacity and visual quality.

where each method puts the message

The same 2.5 KB message, hidden four ways. Sequential LSB fills the image from the top regardless of content. Random LSB spreads over everything, flat areas included. The adaptive methods trace the edges and settle in the texture, and they leave the flat rectangle untouched - the receiver still finds them, because the map that ranks the pixels is computed from bits that embedding never changes.


Table of contents


Installation

Python 3.10 or newer, on Windows, macOS or Linux.

git clone https://github.com/RE22GDV/AdaptiveStego.git
cd AdaptiveStego
pip install -r requirements.txt

Or install the package itself, which also puts adaptivestego and adaptivestego-gui on your PATH:

pip install -e ".[all]"

The library needs only numpy and opencv-python. Everything else is optional: cryptography for password protection, reedsolo for error correction, pandas/matplotlib/scipy for the experiment scripts. Missing optional packages are reported with the exact command to install them, never as an import traceback.

The desktop interface uses tkinter, which ships with CPython on Windows and macOS. Some Linux distributions package it separately:

sudo apt install python3-tk        # Debian, Ubuntu
sudo dnf install python3-tkinter   # Fedora
sudo pacman -S tk                  # Arch

Verify the installation, including that it produces bit-identical results to the reference build:

python -m adaptivestego selftest

Usage

Desktop interface

python -m adaptivestego gui

the embed tab

Five tabs: Embed (with a live capacity readout and a map of the pixels that changed), Extract, Analyse (chi-square, SPA, bit-plane statistics, and capacity per method), Benchmark (compare every method on your own images and export the table as CSV) and About (the determinism self-test).

The interface language is chosen in the top right corner - English, Ukrainian or Russian - and the choice is remembered between sessions. Long operations run in a worker thread, so the window stays responsive on large images.

the analyse tab

The same panel in each of the three languages:

the interface in English, Ukrainian and Russian

Command line

# hide a message, encrypted, with error correction
python -m adaptivestego embed -c cover.png -o stego.png -t "meet at 17:40" \
    --method adaptive --key my-key --password --ecc 16

# recover it
python -m adaptivestego extract -i stego.png --method adaptive --key my-key --password

# how much would fit
python -m adaptivestego capacity -i cover.png --method adaptive

# is there anything in this image?
python -m adaptivestego analyze -i suspect.png

# quality of a cover/stego pair, and attacks on a container
python -m adaptivestego metrics -c cover.png -s stego.png
python -m adaptivestego attack -i stego.png -o attacked.png --attack jpeg:quality=90

Passwords are never taken from the command line by default: --password without a value prompts for it, and --password-env VAR reads it from the environment, so it does not end up in the process list or the shell history.

Python API

import adaptivestego as sl

cover = sl.read_image("cover.png")
result = sl.embed(cover, "secret", method="adaptive", key="my-key",
                  password="pw", ecc_nsym=16)
sl.write_image("stego.png", result.stego)

print(result.summary())
# {'method': 'adaptive', 'payload_bytes': 91, 'bpp': 0.0071, ...}

message = sl.extract(sl.read_image("stego.png"), method="adaptive",
                     key="my-key", password="pw")

For experiments there is a raw mode that writes exactly the bits you give it, with no container header, so a payload of 0.4 bpp means exactly that:

bits = sl.payload_bits_for_bpp(cover, 0.4)          # 0.4 * pixels, rounded
payload = adaptivestego.prng.deterministic_bits("seed-material", "payload", bits)
result = sl.embed_raw(cover, payload, method="adaptive", key="k")
back = sl.extract_raw(result.stego, bits, method="adaptive", key="k")

The method, the key and the map settings are not stored in the image. The receiver has to know them; only what is needed to validate the data is embedded (see the container format).

How it works

flowchart LR
    MSG["message"] --> U8["UTF-8"]
    U8 --> ZLIB["zlib<br/>(only if it helps)"]
    ZLIB --> AES["AES-256-GCM<br/>key from scrypt"]
    AES --> HDR["ASG1 header<br/>magic, flags, CRC32"]
    HDR --> RS["Reed-Solomon"]
    RS --> BITS["bit stream"]

    COVER["cover image"] --> MASK["clear the low bits"]
    MASK --> MAP["complexity map<br/>integer arithmetic"]
    MAP --> BAND["quantise into bands"]
    KEY["key"] --> TIE["keyed Philox labels"]
    BAND --> ORDER["order by band, then label"]
    TIE --> ORDER
    ORDER --> WRITE["write the bits"]
    BITS --> WRITE
    COVER --> WRITE
    WRITE --> STEGO["stego image"]
Loading

Two design decisions carry most of the weight.

The map is computed from the image with its lowest bits cleared - exactly the bits that embedding is allowed to change. Sender and receiver therefore derive the same ranking of pixels from different images, and no synchronisation data has to be transmitted. For +/-1 embedding the direction of the change is constrained so that a carry can never reach the bits the map depends on.

Everything on that path is integer arithmetic. A one-ULP difference in a floating point map on another machine would move a pixel into a neighbouring band, shift the whole ordering and destroy the message. Convolutions run in CV_16S, window sums through an integral image in int64, normalisation through exact order statistics, and the entropy table is computed with decimal rather than the platform's libm.

Full diagrams, including extraction and the lazy ordering, are in docs/architecture.md.

Embedding methods

Method Order of positions Bit written Role
sequential consecutive, from the top-left LSB replacement baseline, trivially detected
random keyed permutation LSB replacement keyed baseline
matching keyed permutation +/-1 outside the reach of chi-square and SPA
edge Sobel gradient LSB replacement classic edge-adaptive LSB
adaptive combined complexity map LSB replacement the proposed method
adaptive-matching combined complexity map +/-1 the proposed method with +/-1
stc none - raster order +/-1 syndrome coding with this project's cost model
wow none - raster order +/-1 syndrome coding with the WOW cost model
uniward none - raster order +/-1 syndrome coding with the S-UNIWARD cost model

The combined map sums the normalised Sobel gradient, local variance, local entropy, a high-pass response and the spread between colour channels, then weights the channels by how visible a change is in each.

The last three are different in kind from the other six. It does not rank anything: the message is the syndrome of the whole stego bit vector under a keyed parity check matrix, and the encoder searches the trellis for the cheapest vector that satisfies it. The decoder therefore needs no cost map at all - which also means the map may be built from the untouched cover at full precision. See docs/syndrome-coding.md.

They share one coder and differ only in the cost model, which is what makes them comparable. wow and uniward are ports of the reference implementations published by the Binghamton DDE Lab, and they are checked against that MATLAB code rather than assumed correct: on four test images the costs agree to 4e-11 or better once clamped at a cost no embedder would ever act on, and the maps of unusable samples match exactly. The comparison is pinned by committed reference vectors, so it re-runs on every commit without MATLAB (see matlab/README.md).

It was worth doing. The first comparison failed by a factor of five: MATLAB's conv2(..., 'same') starts one sample later than the same call in OpenCV or SciPy, and with two convolutions per filter that shifted every cost map by two pixels. The two files also disagree on the wet cost - 10^10 in WOW.m, 10^8 in S_UNIWARD.m - which no description of either algorithm mentions.

Experimental results

Everything below is reproduced by the scripts in experiments/. The design matters as much as the numbers, so it is stated first.

Research mode. Payloads are raw: exactly bpp x pixels pseudo-random bits, with no container header. The application container carries a fixed signature, flags and checksums, and those constant bytes would become part of the stego signal - an extra variable that has nothing to do with the algorithm under test. At 0.4 bpp on a 512x512 cover the payload is exactly 104858 bits.

One key and one payload per case. Every (cover, replicate) pair derives its own embedding key and its own payload from the cover content, the replicate index and a master key:

case_id       = SHA-256(cover bytes + shape + replicate)
embedding key = HMAC-SHA256(master key, "placement/" + case_id)
payload       = HMAC-SHA256(master key, "payload/"   + case_id) -> Philox stream

A single fixed key and a single fixed payload would paint the same spatial pattern into every image of the same size, and a neural detector would learn that pattern instead of the embedding algorithm. The derivation is content addressed, so the whole experiment regenerates from one master key on any machine.

Confidence intervals are clustered. Three replicates of one cover are not three independent observations - image content dominates every metric here. Replicates are averaged within a cover first, and the intervals come from a bootstrap over the covers. Method comparisons are paired on the cover, because every method sees exactly the same images.

python data/download_bossbase.py                                # 1.6 GB, once
python experiments/benchmark.py --config configs/bossbase.json  # 200 covers
python experiments/report.py results/bossbase-smoke.csv
python experiments/ablation.py --images "data/bossbase/*.pgm"     --grayscale --limit 200 --seeds 1 --jobs 0 --out results/bb

About 25 minutes on 32 cores for the first, 10 for the second. The exact files used are named by docs/results/bossbase-manifest.json, which records the image count and a digest of the whole collection.

Detectability on BOSSBase

200 covers from BOSSBase 1.01, three independent embedding realisations each, detectability from sample pair analysis (SPA). A clean cover scores 0.0132. Intervals are 95% cluster bootstrap over the 200 covers.

detectability on BOSSBase

Method 0.05 bpp 0.1 bpp 0.2 bpp 0.4 bpp PSNR @0.4
sequential 0.0749 0.1390 0.2597 0.4630 55.12 dB
random 0.0597 0.1094 0.2083 0.4059 55.12 dB
adaptive 0.0180 0.0277 0.0565 0.1444 55.12 dB
edge 0.0168 0.0245 0.0496 0.1334 55.12 dB
adaptive-matching 0.0150 0.0191 0.0331 0.0796 55.12 dB
matching 0.0166 0.0199 0.0263 0.0383 55.12 dB
stc 0.0136 0.0142 0.0164 0.0232 57.89 dB
wow 0.0133 0.0139 0.0165 0.0229 58.20 dB
uniward 0.0135 0.0140 0.0167 0.0234 58.43 dB

Real photographs separate the methods far more sharply than synthetic covers do. Random LSB reaches 0.41 at 0.4 bpp where a synthetic cover gave 0.13, because natural images have exactly the pixel-pair structure SPA was built to exploit. Against that, at 0.4 bpp and paired on the cover:

Method SPA difference per cover Ratio Better on
adaptive -0.2615 [-0.2699, -0.2532] 0.34x 200 of 200
adaptive-matching -0.3263 [-0.3317, -0.3208] 0.18x 200 of 200
stc -0.3827 [-0.3867, -0.3782] 0.017x 200 of 200
uniward -0.3825 [-0.3867, -0.3776] 0.013x 200 of 200
wow -0.3830 [-0.3872, -0.3784] 0.012x 200 of 200

The syndrome-coded methods sit at the cover baseline within the interval, and they get there while also raising PSNR by three decibels, because minimising distortion means changing far fewer samples for the same payload. The ordering codecs cannot do that: they change one sample per bit by construction.

As before, SPA cannot see +/-1 embedding at all, so the bottom four rows say "this attack does not apply" rather than "this is undetectable". Separating them needs a detector that targets +/-1 directly.

Cost models compared with the coder held fixed

stc, wow and uniward run the same syndrome coder over the same covers with the same payloads. Only the cost model differs, so the comparison is about the cost model alone. On the same 200 BOSSBase covers:

Payload bits per change 0.05 bpp 0.1 bpp 0.2 bpp 0.4 bpp
S-UNIWARD 6.26 5.81 5.16 4.29
WOW 5.37 5.15 4.74 4.07
this project 4.97 4.71 4.34 3.79

Our cost model comes third of three, on both counts. At 0.4 bpp S-UNIWARD changes 9.33 % of the samples where ours changes 10.58 %, and it is also the faster to compute: 149 ms against our 513 ms per megapixel, because it takes three convolutions where we build five sub-maps.

That is the honest state of the work. The complexity map was designed to rank samples for the ordering codecs, not to price them, and its floor and gamma have never been fitted. Improving it is a roadmap item, with the note that fitting must not happen on the data the result is reported on.

Ablation: which complexity map matters

ablation on BOSSBase

SPA estimate for the adaptive codec with one map at a time, same 200 covers:

Map 0.1 bpp 0.2 bpp 0.4 bpp
sobel 0.0243 0.0493 0.1325
combined 0.0277 0.0567 0.1449
entropy 0.0283 0.0586 0.1513
variance 0.0301 0.0617 0.1674
highfreq 0.0451 0.0971 0.2286
laplacian 0.0479 0.1033 0.2425
uniform (control) 0.1095 0.2084 0.4056
chroma 0.1098 0.2086 0.4070
  • The control lands exactly where it should. A uniform map removes every content preference, and the adaptive codec then scores 0.4056 against random LSB's 0.4059. The gain comes from the content of the map, not from the machinery around it.
  • The plain Sobel map still beats the combined one, as it did on synthetic covers. The combined weights were chosen a priori and have deliberately not been tuned on these results.
  • chroma is meaningless here and the number shows it: BOSSBase is grayscale, so the inter-channel spread is identically zero and the map degenerates into the control.

Robustness and error correction

Application mode, 0.2 bpp, fraction of messages recovered in full:

Method Attack no ECC 8 16 32
random pixel damage, p=0.0002 0.33 1.00 1.00 1.00
random pixel damage, p=0.001 0.00 0.96 1.00 1.00
random salt and pepper, p=0.0002 0.33 1.00 1.00 1.00
adaptive any of the above 0.00 0.00 0.00 0.00

Error correction rescues the non-adaptive methods completely and does nothing at all for the adaptive ones. That is not a failure of the ECC: the receiver rebuilds the order of positions from the image, so one sample that moves into a different band after an attack shifts every subsequent bit. Syndrome coding (STC) is what removes that dependence - see the roadmap.

No method survives JPEG, rescaling or noise above sigma = 0.3. That is a property of LSB embedding in the spatial domain, not of this implementation.

Performance

Measured on Windows 11, Python 3.12, single core, 0.2 bpp payload (python experiments/performance.py):

performance

Method 0.07 Mpx 0.26 Mpx 1.0 Mpx 4.2 Mpx Peak memory @1 Mpx
sequential 0 ms 1 ms 2 ms 12 ms 6 MB
random 5 ms 25 ms 105 ms 452 ms 72 MB
matching 6 ms 26 ms 113 ms 482 ms 72 MB
edge 10 ms 48 ms 291 ms 1.1 s 107 MB
adaptive 23 ms 125 ms 886 ms 3.7 s 107 MB
adaptive-matching 24 ms 141 ms 957 ms 3.6 s 107 MB
stc 985 ms 4.2 s 17.7 s 65.1 s 249 MB

Extraction costs about the same as embedding for the ordering codecs. Syndrome coding is the exception in both directions: embedding runs a Viterbi pass over 2^height states and is roughly fifty times slower, while extraction is only a syndrome computation and is faster than the adaptive codecs - 0.6 s against 0.9 s at one megapixel. Its memory grows with the trellis: 249 MB at one megapixel, 1.0 GB at four.

Syndrome coding does not order anything, so the shortcut below does not apply to it; its cost is the trellis search itself.

The ordering is built lazily. A message almost never fills an image, so ordering every sample of a photograph to write a few kilobytes is wasted work. Each codec can produce just the first N positions: a partition for the keyed methods, a histogram of the bands for the adaptive ones. Both return exactly the prefix of the full ordering, ties included, which the test suite verifies for every codec and every limit.

At 1 Mpx Before After Speed-up
adaptive, embed 6548 ms 886 ms 7.4x
adaptive, extract 3267 ms 891 ms 3.7x
random, embed 1974 ms 105 ms 19x
random, extract 947 ms 106 ms 8.9x

The determinism digest is unchanged by the optimisation, which is the strongest statement available that the output is byte for byte identical.

Reproducibility

An adaptive stego image only decodes if the receiver's complexity map matches the sender's exactly. "It should be deterministic" is not worth much without a way to check, so:

$ python -m adaptivestego selftest
python 3.12.10  numpy 2.1.2  opencv 4.13.0
platform Windows-11-10.0.26200-SP0 (AMD64)
methods checked: sequential, random, matching, edge, adaptive, adaptive-matching
digest   e9bdd51a26f7d5a9395135fa91869964ad72975be9b64108e33922c99c8959bc
expected e9bdd51a26f7d5a9395135fa91869964ad72975be9b64108e33922c99c8959bc
OK: this installation is interoperable with the reference build

The digest covers every complexity map, the keyed permutation, the position order of every codec, the packed container and the resulting stego images. Two machines that print the same digest can exchange stego images.

CI checks this on every commit and the guarantee holds in practice: Windows, macOS and Linux, Python 3.10 and 3.12, numpy 2.1 and 2.2, OpenCV 4.13 and 5.0 all produce the digest above, byte for byte.

Experiment runs also write a *.meta.json next to their results with the full command line, the library version, the key and the seeds.

What it does not do

  • It does not survive image processing. JPEG, rescaling, cropping and noise destroy the message. Send stego images as PNG or BMP; write_image refuses to write a lossy format.
  • It is not encryption by itself. Without --password the payload is stored in the clear and anyone who knows the method and the key can read it. Steganography hides that a message exists; cryptography protects what it says.
  • The detectors here are a weak baseline. Chi-square and SPA see LSB replacement and are blind to +/-1. A claim about "lower detectability" only becomes real against an SRNet-class detector, on BOSSBase or ALASKA#2.
  • The results above are on synthetic covers. They demonstrate that the bench works and that the effect exists; they are not a publishable finding.
  • Robustness numbers depend on the mode. Error correction only exists in the application container; research mode carries the payload and nothing else.

The full threat model is in docs/limitations.md.

Project layout

src/adaptivestego/
  api.py          embed / extract / capacity
  container.py    the ASG1 format: header, flags, checksums
  crypto.py       scrypt + AES-256-GCM
  ecc.py          Reed-Solomon
  core.py         writing and reading bits at given positions
  maps.py         integer complexity maps
  costs.py        the same maps read as per-direction embedding costs
  stc.py          syndrome-trellis coding, exact minimum-distortion search
  prng.py         deterministic keyed ordering
  codecs/         the seven methods behind one registry
  metrics.py      PSNR, SSIM, BER, embedding efficiency
  attacks.py      twelve container distortions
  analysis.py     chi-square, SPA, bit-plane statistics
  selftest.py     the cross-platform determinism digest
  gui.py          tkinter interface
  i18n.py         English, Ukrainian, Russian
  cli.py          command line interface
experiments/      benchmark, report, performance, figures
docs/             format, architecture, syndrome coding, limitations, protocol
tests/            111 tests plus a runner that works without pytest
legacy/           the original decoder.py this project grew out of

Development

pip install -r requirements-dev.txt
pytest -q                    # the test suite
python tests/run_tests.py    # the same suite without pytest
ruff check src tests experiments examples
python -m adaptivestego selftest

CI runs the suite on Windows, macOS and Linux for Python 3.10 through 3.13, plus a job with only numpy and opencv installed to make sure the optional dependencies really are optional. The Linux jobs run the GUI tests under xvfb rather than skipping them.

For a frozen environment there is requirements-lock.txt (exact versions of every direct and transitive dependency) and a Dockerfile:

docker build -t adaptivestego .
docker run --rm adaptivestego selftest

Where the project is going next - syndrome coding, an SRNet detector, runs on BOSSBase and ALASKA#2 - is written down in docs/roadmap.md.

Citation and licence

If this code is useful in your research, cite it through CITATION.cff. Released under the MIT licence.

About

AdaptiveStego - content-adaptive LSB image steganography and steganalysis bench: complexity-map placement, authenticated container, metrics, attacks, and a multilingual desktop UI

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages