- Go 100%
Reed-Solomon RS(56,40) errata coding (2e+s<=16); Watson 1993 perceptual masking; AAN scaled DCT; gonum FFT crop+scale sync recovery; adaptive 64/128 px tiles with 4096 px / 16 MP decompression-bomb cap; bounded per-tile concurrency with ctx-aware row-level cancellation across toRGBA, splitYCbCr, mergeYCbCr, sync, resample, quality and tile work. KeyIDSize widened 4 to 8 bytes (PayloadVersion 2) to eliminate 32-bit hash collisions; HMAC pair label rebumped to verum-pairs-v2. Embed retry loop fails fast on permanent errors and only retries ErrQualityGateFailed; pre-encode and post-encode quality gates both enforced; JPEG output rejects alpha-bearing inputs; paletted PNGs with tRNS and NRGBA pixels with alpha below the visibility floor preserve their hidden RGB end-to-end. QualityConfig validates NaN/Inf/range and non-negative MaxRetries; DetectionConfig.Scales validated, additive, and capped at 8 entries. Calibration corpus of 50 procedural PNGs (gradient/dark/portrait/screenshot/noisy x10) gates production constants within +-30% of corpus-derived percentiles, enforces 80% per-claim downscale success and 30% aggregate embed-failure ceiling. Capability claims, known limits, and public API surface are aligned across README, NOTICE, CHANGELOG, and CONTRIBUTING. |
||
|---|---|---|
| internal | ||
| testdata | ||
| .gitignore | ||
| bench_test.go | ||
| CHANGELOG.md | ||
| config.go | ||
| CONTRIBUTING.md | ||
| detect.go | ||
| embed.go | ||
| errors.go | ||
| examples_test.go | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| NOTICE | ||
| payload.go | ||
| payload_test.go | ||
| README.md | ||
| sync.go | ||
| verum.go | ||
| verum_test.go | ||
| watson.go | ||
| watson_test.go | ||
mtn-verum
Keyed image marking with measurable distortion bounds.
mtn-verum writes a cryptographically-keyed mark directly into image pixels so images generated by an MTN service can be identified later, without relying on metadata that most uploaders strip.
go get github.com/MTN-Media-Group/mtn-verum
Quick start
import (
"context"
"os"
"time"
verum "github.com/MTN-Media-Group/mtn-verum"
)
key := verum.Key{ID: "k1", Secret: []byte("a high-entropy secret of at least 16 bytes")}
cfg := verum.Config{
ActiveKey: key,
DetectionKeys: []verum.Key{key},
Strength: verum.StrengthBalanced,
// JPEGQuality: 0 uses the public API default of 95 for JPEG output.
}
payload := verum.Payload{
GeneratedAt: time.Now(),
Provider: "mtnai",
Model: "flux.1-pro",
GenerationID: "gen_abc123",
}
src, err := os.ReadFile("input.png")
if err != nil {
panic(err)
}
ok, err := verum.IsEmbeddable(src, cfg)
if err != nil || !ok {
panic(err)
}
res, err := verum.Embed(context.Background(), src, "image/png", payload, cfg)
if err != nil {
panic(err)
}
// res.Data carries the marked image; res.Quality has luminance-plane SSIM, PSNR, MaxDelta.
det, err := verum.Detect(context.Background(), res.Data, "image/png", cfg)
if err != nil {
panic(err)
}
// det.Detected, det.KeyID, det.PayloadDigest, det.Confidence, det.BestScale.
Verify is Detect plus a digest check against an expected Payload.
Key secrets must be at least 16 bytes; 32 bytes or more is recommended.
Current PayloadVersion is 2; the on-image frame carries an 8-byte SHA-256-truncated key ID, a 32-byte HMAC digest, and Reed-Solomon parity in a 60-byte frame.
Capabilities
| Capability | Current |
|---|---|
| PNG embed/detect at native scale | ✓ |
JPEG transcode survival at Q95 with StrengthRobust |
✓ |
| Small top-left crop offsets up to 2 tile periods, when the original image dimensions are a multiple of the tile size and the cropped image retains at least 5 tiles per side | ✓ |
0.75x bilinear, 0.5x nearest-neighbor, and 2048→1024 bilinear large-image downscale detection with StrengthRobust. |
✓ |
| Reed-Solomon: 16 parity bytes; corrects up to 8 byte errors (or 2e + s <= 16 errors+erasures) via syndrome decoding | ✓ |
| WebP decode and lossless encode (no CGO) | ✓ |
| Multi-key detection / key rotation | ✓ |
| Premultiplied alpha round-trip preserved | ✓ |
| Fully transparent pixels untouched | ✓ |
Calibration thresholds: the generated corpus fixtures are 512px PNGs; recordScaleSweep pre-resizes each fixture to a 1024px PNG before embedding. The resulting 1024-source downscale points (0.5x nearest 1024→512 and 0.75x bilinear 1024→768) are validated against the calibration corpus at >= 80% success per claim. Full-corpus calibration also enforces <= 30% aggregate embed failures across all profiles. The 2048→1024 large-image path, the 384→288 0.75x boundary, JPEG Q95 round-trip, and crop survival at the retained-tile floor are verified by direct fixture tests, not by corpus sweep.
Downscale survival requires the resized image to keep both dimensions at least MinImageDim (256px), so the shortest side is the limiting dimension. Current measured coverage is 1024→512 for 0.5x nearest-neighbor, 1024→768 for 0.75x bilinear, 2048→1024 for 0.5x bilinear on the large-image path, and 384→288 for 0.75x bilinear at the boundary. The 1024→512 bilinear path is not claimed for PayloadVersion 2.
Known limitations
- Pure-flat images with no texture and no edges cannot carry the mark;
EmbedreturnsErrNoCapacity, andIsEmbeddablecan report this before embedding. - Photos with very smooth regions, including extreme close-ups and gradient skies, may have reduced robustness.
- 1024→512 0.5x bilinear recovery is below the current corpus claim threshold.
- JPEG below quality 95 is not supported in the current release.
- JPEG Q75/Q85 transcode survival is not claimed under the default Robust quality gates.
- Lossy WebP output is not supported; the project is pure-Go and has no CGO bindings to libwebp.
Detection details
DetectResult.Details uses stable numeric keys for observability:
bit_confidence_bucket_0throughbit_confidence_bucket_4: fraction of recovered bits in each confidence bucket.supporting_tiles_ratio: supporting tiles divided by checked tiles.tiles_checked: number of candidate tiles inspected.sync_peak_strength: FFT sync peak strength relative to nearby candidate frequencies.scale_estimate: estimated resize factor before detection.crop_x_pixelsandcrop_y_pixels: estimated grid offset in pixels.Config.Detection.Scalesadds caller-supplied scale hints to the default 0.75 and 0.5 sweep; duplicate scales are ignored.
Strength profiles
| Profile | Default min SSIM | Default min PSNR | Default max delta | Default MaxChangeRatio |
|---|---|---|---|---|
| Invisible | 0.999 | 50 dB | 12 | 0.2 |
| Balanced | 0.997 | 46 dB | 18 | 0.4 |
| Robust | 0.985 | 38 dB | 80 | 0.6 |
These thresholds are luminance-plane SSIM/PSNR/MaxDelta checked pre-encode, and re-validated post-encode for lossy formats (JPEG); JPEG output may alter chroma post-encode.
Embed retries with a lower same-profile coefficient delta if the requested gates fail.
Status
Pre-release. The public API and the on-image wire format may still change.
License
GNU AGPL-3.0-only. See LICENSE and NOTICE. MTN Media Group, as copyright holder, reserves the right to use, modify, and distribute this software under alternative license terms, including in proprietary projects.
Contributing
See CONTRIBUTING.md. Contributions are dual-licensed: public AGPL-3.0-only plus a perpetual, irrevocable grant to MTN Media Group permitting proprietary use.
Prior art
This is an independent implementation of frequency-domain keyed image watermarking, a technique with decades of public prior art. The canonical citation is in NOTICE. It is not derived from, affiliated with, or compatible with any third-party watermarking system.