Skip to content

Getting Started

Installation

pip install sigil-watermark

Or with uv:

uv add sigil-watermark

Key Generation

Every author needs an Ed25519 keypair. The public key is shared openly; the private key is kept secret.

from sigil_watermark import generate_author_keys

# Random keypair
keys = generate_author_keys()

# Deterministic keypair from a 32-byte seed (reproducible)
keys = generate_author_keys(seed=b"my-secret-seed-exactly-32-bytes!")

# Access the raw key bytes
print(len(keys.public_key))   # 32
print(len(keys.private_key))  # 32

Embedding a Watermark

RGB Images

import numpy as np
from PIL import Image
from sigil_watermark import SigilEmbedder, generate_author_keys

keys = generate_author_keys()
embedder = SigilEmbedder()

# Load as float64 RGB (0-255)
image = np.array(Image.open("photo.jpg").convert("RGB"), dtype=np.float64)

# Embed -- returns float64 array same shape as input
watermarked = embedder.embed(image, keys)

# Save
output = np.clip(watermarked, 0, 255).astype(np.uint8)
Image.fromarray(output).save("watermarked.png")

Grayscale Images

image = np.array(Image.open("drawing.png").convert("L"), dtype=np.float64)
watermarked = embedder.embed(image, keys)  # works with 2D arrays too

Image Requirements

  • Dtype: float64, values in [0, 255]
  • Shape: (H, W) for grayscale or (H, W, 3) for RGB
  • Minimum size: 64x64 (larger images produce stronger watermarks)
  • Even dimensions: Recommended for best DWT performance (odd dims are handled but may reduce quality)

Detecting a Watermark

from sigil_watermark import SigilDetector

detector = SigilDetector()

# Detect with a candidate public key
result = detector.detect(watermarked, keys.public_key)

Understanding DetectionResult

result.detected            # bool: watermark found?
result.author_id_match     # bool: author ID matches the provided key?
result.confidence          # float: overall confidence (0-1)
result.ring_confidence     # float: Layer 1 (DFT rings) signal strength
result.payload_confidence  # float: Layer 2 (DWT payload) signal strength
result.ghost_confidence    # float: Layer 3 (ghost signal) strength
result.beacon_found        # bool: universal Sigil beacon detected?
result.tampering_suspected # bool: sentinel rings intact but key rings removed?
result.ghost_hash          # list[int] | None: extracted ghost hash bits
result.ghost_hash_match    # bool: ghost hash matches candidate key?

A typical detection on an uncompressed watermarked image:

Field Value
detected True
author_id_match True
confidence 0.7
ring_confidence 0.3-0.8
payload_confidence 0.85-1.0
ghost_confidence 0.3-0.6

JPEG Robustness

The watermark survives standard web compression:

import io
from PIL import Image

# Embed
watermarked = embedder.embed(image, keys)

# JPEG roundtrip at Q75
buf = io.BytesIO()
Image.fromarray(np.clip(watermarked, 0, 255).astype(np.uint8)).save(buf, "JPEG", quality=75)
buf.seek(0)
compressed = np.array(Image.open(buf).convert("RGB"), dtype=np.float64)

# Still detectable
result = detector.detect(compressed, keys.public_key)
print(result.detected)          # True
print(result.author_id_match)   # True

Custom Configuration

from sigil_watermark import SigilConfig, SigilEmbedder, SigilDetector

config = SigilConfig(
    ring_strength=25.0,           # Stronger rings (default: 20.0)
    embed_strength=4.0,           # Stronger payload (default: 3.0)
    adaptive_ring_strength=True,  # Adapt to image content (default: True)
    ring_target_psnr=38.0,        # Higher quality target (default: 36.0)
)

# Both embedder and detector must use the same config
embedder = SigilEmbedder(config=config)
detector = SigilDetector(config=config)

See the Configuration API for all available parameters.