Skip to content

API Overview

The top-level sigil_watermark package exports the main classes and functions:

Sigil Watermark -- invisible, crypto-verified, AI-training-resilient image watermarks.

A pure signal-processing watermark system with three independent embedding layers and three-tier detection. No neural networks required.

Example

from sigil_watermark import SigilEmbedder, SigilDetector, generate_author_keys keys = generate_author_keys() embedder = SigilEmbedder() detector = SigilDetector() watermarked = embedder.embed(image, keys) result = detector.detect(watermarked, keys.public_key) result.detected True

DEFAULT_CONFIG = SigilConfig() module-attribute

SigilEmbedder

Embeds the three-layer Sigil watermark into an image.

The embedder applies three complementary layers, each targeting a different attack class:

  1. DFT Ring Anchor — concentric rings in the Fourier magnitude spectrum survive geometric transforms (rotation, scale, crop).
  2. DWT Spread-Spectrum — CDMA-encoded payload tiled across wavelet subbands carries the beacon, author index, and author ID.
  3. Ghost Signal — multiplicative spectral modulation at VAE-passband frequencies survives AI training pipelines (Stable Diffusion VAE).

Parameters:

Name Type Description Default
config SigilConfig

Watermark configuration. Defaults to :data:DEFAULT_CONFIG.

DEFAULT_CONFIG
Example

from sigil_watermark import SigilEmbedder, generate_author_keys keys = generate_author_keys(seed=b"example") embedder = SigilEmbedder() watermarked = embedder.embed(image, keys)

Source code in src/sigil_watermark/embed.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
class SigilEmbedder:
    """Embeds the three-layer Sigil watermark into an image.

    The embedder applies three complementary layers, each targeting a
    different attack class:

    1. **DFT Ring Anchor** — concentric rings in the Fourier magnitude
       spectrum survive geometric transforms (rotation, scale, crop).
    2. **DWT Spread-Spectrum** — CDMA-encoded payload tiled across wavelet
       subbands carries the beacon, author index, and author ID.
    3. **Ghost Signal** — multiplicative spectral modulation at VAE-passband
       frequencies survives AI training pipelines (Stable Diffusion VAE).

    Args:
        config: Watermark configuration. Defaults to :data:`DEFAULT_CONFIG`.

    Example:
        >>> from sigil_watermark import SigilEmbedder, generate_author_keys
        >>> keys = generate_author_keys(seed=b"example")
        >>> embedder = SigilEmbedder()
        >>> watermarked = embedder.embed(image, keys)
    """

    def __init__(self, config: SigilConfig = DEFAULT_CONFIG):
        self.config = config

    def embed(self, image: np.ndarray, author_keys: AuthorKeys) -> np.ndarray:
        """Embed the full Sigil watermark into an image.

        Applies all three layers sequentially. The image can be grayscale
        or RGB — color images are embedded in the Y (luminance) channel
        only, preserving chrominance.

        Args:
            image: Input image as a float64 NumPy array, pixel values 0–255.
                Accepts grayscale ``(H, W)`` or RGB ``(H, W, 3)``.
            author_keys: Author's Ed25519 keypair (see :func:`generate_author_keys`).

        Returns:
            Watermarked image as float64, same shape and format as input.

        Raises:
            ValueError: If image dimensions are too small for the configured
                tile sizes.
        """
        cfg = self.config

        # Handle color: extract Y channel, embed in Y, reconstruct
        y_channel, color_meta = prepare_for_embedding(image)
        result = y_channel.copy()

        # Compute perceptual mask for adaptive embedding strength
        mask = compute_perceptual_mask(result, config=cfg)

        # --- Layer 1: DFT Ring Anchor ---
        # Key-derived rings + sentinel rings (stable — positions don't depend on image)
        key_radii = derive_ring_radii(author_keys.public_key, config=cfg)
        sentinel_radii = derive_sentinel_ring_radii(config=cfg)
        stable_radii = np.sort(np.concatenate([key_radii, sentinel_radii]))
        stable_phase = derive_ring_phase_offsets(
            author_keys.public_key, len(stable_radii), config=cfg
        )
        # Content-dependent rings (positions depend on image hash + key)
        content_radii = derive_content_ring_radii(author_keys.public_key, result, config=cfg)
        # embed_dft_rings uses per-ring alpha = (strength/50) * (4/num_rings).
        # To keep total energy constant across separate calls, scale strength
        # so each subset gets the same per-ring alpha as if all rings were in one call.
        total_rings = len(stable_radii) + len(content_radii)
        stable_strength = cfg.ring_strength * len(stable_radii) / total_rings
        content_strength = cfg.ring_strength * len(content_radii) / total_rings

        adaptive_psnr = cfg.ring_target_psnr if cfg.adaptive_ring_strength else None
        result = embed_dft_rings(
            result,
            stable_radii,
            strength=stable_strength,
            ring_width=cfg.ring_width,
            phase_offsets=stable_phase,
            target_psnr=adaptive_psnr,
            min_alpha_fraction=cfg.ring_min_alpha_fraction,
        )
        result = embed_dft_rings(
            result,
            content_radii,
            strength=content_strength,
            ring_width=cfg.ring_width,
            target_psnr=adaptive_psnr,
            min_alpha_fraction=cfg.ring_min_alpha_fraction,
        )

        # --- Layer 2: Fractal Sigil Tiling ---
        # Combined RS-encoded payload: beacon + index + author_id
        encoded_payload = build_payload(author_keys, cfg)

        # Use universal beacon PN for the tiled payload so blind detection works
        h, w = result.shape
        max_tile = max(cfg.tile_sizes)
        pn_length = max(h * w, max_tile * max_tile)
        payload_pn = get_universal_beacon_pn(length=pn_length, config=cfg)

        # Build composite ghost PN encoding author's ghost hash bits
        ghost_pn = build_ghost_composite_pn(author_keys.public_key, length=pn_length, config=cfg)

        # DWT decompose
        coeffs = dwt_decompose(result, wavelet=cfg.wavelet, level=cfg.dwt_levels)

        # Embed tiled payload in each DWT level's detail subbands
        for level_idx in range(1, len(coeffs)):
            detail_tuple = coeffs[level_idx]
            subband_names = ("LH", "HL", "HH")

            new_details = list(detail_tuple)
            for sb_idx, sb_name in enumerate(subband_names):
                if sb_name not in cfg.embed_subbands:
                    continue

                subband = new_details[sb_idx].copy()
                sh, sw = subband.shape
                mean_mask = _resize_mask(mask, sh, sw).mean()

                ts = best_tile_size((sh, sw), cfg.tile_sizes, len(encoded_payload))

                subband = tile_embed(
                    subband,
                    payload_pn,
                    encoded_payload,
                    tile_size=ts,
                    strength=cfg.embed_strength * mean_mask,
                    spreading_factor=cfg.spreading_factor,
                )

                new_details[sb_idx] = subband

            coeffs[level_idx] = tuple(new_details)

        # Reconstruct from modified DWT coefficients
        result = dwt_reconstruct(coeffs, wavelet=cfg.wavelet)
        result = result[: image.shape[0], : image.shape[1]]

        # --- Layer 3: Training Ghost Signal ---
        # Ghost is embedded in Y channel only. Multi-channel (RGB) embedding
        # was tested but hurts VAE survival — the SD VAE mixes channels in its
        # latent space, destroying per-channel PN coherence.
        # Ghost PN encodes author's ghost hash bits for blind author binning.
        result = self._embed_ghost_signal(result, ghost_pn, mask)

        # Reconstruct color image if needed
        return reconstruct_from_embedding(result, color_meta)

    def _embed_ghost_signal(
        self, image: np.ndarray, author_pn: np.ndarray, mask: np.ndarray
    ) -> np.ndarray:
        """Embed training ghost signal via multiplicative modulation.

        Uses multiplicative embedding at ghost frequency bands: the image's
        existing spectrum magnitude is modulated by ±strength based on the
        PN sequence sign. This makes the ghost signal proportional to image
        energy (robust to natural image spectral variation) and survives
        VAE encode/decode because the magnitude pattern is partially preserved.
        """
        cfg = self.config
        h, w = image.shape
        f = np.fft.fft2(image)
        f_shifted = np.fft.fftshift(f)

        cy, cx = h // 2, w // 2
        max_freq = min(h, w) // 2

        y, x = np.ogrid[:h, :w]
        freq_dist = np.sqrt((x - cx) ** 2 + (y - cy) ** 2) / max_freq

        pn_2d = author_pn[: h * w].reshape(h, w)
        # Use PN sign pattern (+1/-1) for multiplicative modulation
        pn_sign = np.sign(pn_2d)

        # Scale ghost modulation depth by perceptual mask: full strength in
        # textured regions, reduced in smooth regions (sky, gradients) where
        # banding from spectral modulation could be visible. Use sqrt for a
        # gentle reduction — the ghost is already very subtle (2% modulation
        # depth at 200×) so aggressive scaling would kill detectability
        # without meaningful quality benefit.
        mask_scale = np.sqrt(mask.mean())
        ghost_mod_depth = cfg.ghost_strength_multiplier / 10000.0 * mask_scale
        for band_freq in cfg.ghost_bands:
            band_mask = np.exp(-((freq_dist - band_freq) ** 2) / (2 * cfg.ghost_bandwidth**2))
            # Multiplicative modulation: f *= (1 + depth * pn_sign * band_mask)
            modulation = 1.0 + ghost_mod_depth * pn_sign * band_mask
            f_shifted *= modulation

        result = np.real(np.fft.ifft2(np.fft.ifftshift(f_shifted)))
        return result

embed(image, author_keys)

Embed the full Sigil watermark into an image.

Applies all three layers sequentially. The image can be grayscale or RGB — color images are embedded in the Y (luminance) channel only, preserving chrominance.

Parameters:

Name Type Description Default
image ndarray

Input image as a float64 NumPy array, pixel values 0–255. Accepts grayscale (H, W) or RGB (H, W, 3).

required
author_keys AuthorKeys

Author's Ed25519 keypair (see :func:generate_author_keys).

required

Returns:

Type Description
ndarray

Watermarked image as float64, same shape and format as input.

Raises:

Type Description
ValueError

If image dimensions are too small for the configured tile sizes.

Source code in src/sigil_watermark/embed.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def embed(self, image: np.ndarray, author_keys: AuthorKeys) -> np.ndarray:
    """Embed the full Sigil watermark into an image.

    Applies all three layers sequentially. The image can be grayscale
    or RGB — color images are embedded in the Y (luminance) channel
    only, preserving chrominance.

    Args:
        image: Input image as a float64 NumPy array, pixel values 0–255.
            Accepts grayscale ``(H, W)`` or RGB ``(H, W, 3)``.
        author_keys: Author's Ed25519 keypair (see :func:`generate_author_keys`).

    Returns:
        Watermarked image as float64, same shape and format as input.

    Raises:
        ValueError: If image dimensions are too small for the configured
            tile sizes.
    """
    cfg = self.config

    # Handle color: extract Y channel, embed in Y, reconstruct
    y_channel, color_meta = prepare_for_embedding(image)
    result = y_channel.copy()

    # Compute perceptual mask for adaptive embedding strength
    mask = compute_perceptual_mask(result, config=cfg)

    # --- Layer 1: DFT Ring Anchor ---
    # Key-derived rings + sentinel rings (stable — positions don't depend on image)
    key_radii = derive_ring_radii(author_keys.public_key, config=cfg)
    sentinel_radii = derive_sentinel_ring_radii(config=cfg)
    stable_radii = np.sort(np.concatenate([key_radii, sentinel_radii]))
    stable_phase = derive_ring_phase_offsets(
        author_keys.public_key, len(stable_radii), config=cfg
    )
    # Content-dependent rings (positions depend on image hash + key)
    content_radii = derive_content_ring_radii(author_keys.public_key, result, config=cfg)
    # embed_dft_rings uses per-ring alpha = (strength/50) * (4/num_rings).
    # To keep total energy constant across separate calls, scale strength
    # so each subset gets the same per-ring alpha as if all rings were in one call.
    total_rings = len(stable_radii) + len(content_radii)
    stable_strength = cfg.ring_strength * len(stable_radii) / total_rings
    content_strength = cfg.ring_strength * len(content_radii) / total_rings

    adaptive_psnr = cfg.ring_target_psnr if cfg.adaptive_ring_strength else None
    result = embed_dft_rings(
        result,
        stable_radii,
        strength=stable_strength,
        ring_width=cfg.ring_width,
        phase_offsets=stable_phase,
        target_psnr=adaptive_psnr,
        min_alpha_fraction=cfg.ring_min_alpha_fraction,
    )
    result = embed_dft_rings(
        result,
        content_radii,
        strength=content_strength,
        ring_width=cfg.ring_width,
        target_psnr=adaptive_psnr,
        min_alpha_fraction=cfg.ring_min_alpha_fraction,
    )

    # --- Layer 2: Fractal Sigil Tiling ---
    # Combined RS-encoded payload: beacon + index + author_id
    encoded_payload = build_payload(author_keys, cfg)

    # Use universal beacon PN for the tiled payload so blind detection works
    h, w = result.shape
    max_tile = max(cfg.tile_sizes)
    pn_length = max(h * w, max_tile * max_tile)
    payload_pn = get_universal_beacon_pn(length=pn_length, config=cfg)

    # Build composite ghost PN encoding author's ghost hash bits
    ghost_pn = build_ghost_composite_pn(author_keys.public_key, length=pn_length, config=cfg)

    # DWT decompose
    coeffs = dwt_decompose(result, wavelet=cfg.wavelet, level=cfg.dwt_levels)

    # Embed tiled payload in each DWT level's detail subbands
    for level_idx in range(1, len(coeffs)):
        detail_tuple = coeffs[level_idx]
        subband_names = ("LH", "HL", "HH")

        new_details = list(detail_tuple)
        for sb_idx, sb_name in enumerate(subband_names):
            if sb_name not in cfg.embed_subbands:
                continue

            subband = new_details[sb_idx].copy()
            sh, sw = subband.shape
            mean_mask = _resize_mask(mask, sh, sw).mean()

            ts = best_tile_size((sh, sw), cfg.tile_sizes, len(encoded_payload))

            subband = tile_embed(
                subband,
                payload_pn,
                encoded_payload,
                tile_size=ts,
                strength=cfg.embed_strength * mean_mask,
                spreading_factor=cfg.spreading_factor,
            )

            new_details[sb_idx] = subband

        coeffs[level_idx] = tuple(new_details)

    # Reconstruct from modified DWT coefficients
    result = dwt_reconstruct(coeffs, wavelet=cfg.wavelet)
    result = result[: image.shape[0], : image.shape[1]]

    # --- Layer 3: Training Ghost Signal ---
    # Ghost is embedded in Y channel only. Multi-channel (RGB) embedding
    # was tested but hurts VAE survival — the SD VAE mixes channels in its
    # latent space, destroying per-channel PN coherence.
    # Ghost PN encodes author's ghost hash bits for blind author binning.
    result = self._embed_ghost_signal(result, ghost_pn, mask)

    # Reconstruct color image if needed
    return reconstruct_from_embedding(result, color_meta)

SigilDetector

Detects and verifies the three-layer Sigil watermark.

Implements three detection tiers:

  1. Beacon — universal marker shared by all Signarture watermarks. Answers "is this image watermarked at all?"
  2. Author Index — 20-bit index for O(1) database lookup. Answers "whose watermark is this?" without the author's key.
  3. Author Verification — cryptographic proof using the author's Ed25519 public key. Answers "does this specific key match?"

Includes automatic geometric correction: if initial detection is weak but rings are found, common rotation corrections are tried.

Parameters:

Name Type Description Default
config SigilConfig

Watermark configuration. Defaults to :data:DEFAULT_CONFIG.

DEFAULT_CONFIG
Example

from sigil_watermark import SigilDetector detector = SigilDetector() result = detector.detect(image, public_key) if result.detected: ... print(f"Watermark found (confidence={result.confidence:.2f})")

Source code in src/sigil_watermark/detect.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
class SigilDetector:
    """Detects and verifies the three-layer Sigil watermark.

    Implements three detection tiers:

    1. **Beacon** — universal marker shared by all Signarture watermarks.
       Answers "is this image watermarked at all?"
    2. **Author Index** — 20-bit index for O(1) database lookup.
       Answers "whose watermark is this?" without the author's key.
    3. **Author Verification** — cryptographic proof using the author's
       Ed25519 public key. Answers "does this specific key match?"

    Includes automatic geometric correction: if initial detection is weak
    but rings are found, common rotation corrections are tried.

    Args:
        config: Watermark configuration. Defaults to :data:`DEFAULT_CONFIG`.

    Example:
        >>> from sigil_watermark import SigilDetector
        >>> detector = SigilDetector()
        >>> result = detector.detect(image, public_key)
        >>> if result.detected:
        ...     print(f"Watermark found (confidence={result.confidence:.2f})")
    """

    def __init__(self, config: SigilConfig = DEFAULT_CONFIG):
        self.config = config

    def _extract_combined_payload(self, image: np.ndarray) -> tuple[list[int], float]:
        """Extract the combined tiled payload from DWT subbands.

        Args:
            image: Grayscale (H,W) or RGB (H,W,3) image.

        Returns:
            (voted_encoded_bits, avg_confidence)
        """
        cfg = self.config
        y = extract_y_channel(image)
        coeffs = dwt_decompose(y, wavelet=cfg.wavelet, level=cfg.dwt_levels)

        max_tile = max(cfg.tile_sizes)
        pn_length = max(image.shape[0] * image.shape[1], max_tile * max_tile)
        payload_pn = get_universal_beacon_pn(length=pn_length, config=cfg)
        encoded_len = _encoded_payload_length(cfg)

        all_bits = []
        all_conf = []

        for level_idx in range(1, len(coeffs)):
            detail_tuple = coeffs[level_idx]
            subband_names = ("LH", "HL", "HH")
            for sb_idx, sb_name in enumerate(subband_names):
                if sb_name not in cfg.embed_subbands:
                    continue
                subband = detail_tuple[sb_idx]

                ts = best_tile_size(subband.shape, cfg.tile_sizes, encoded_len)

                bits, conf = tile_extract(
                    subband,
                    payload_pn,
                    num_bits=encoded_len,
                    tile_size=ts,
                    spreading_factor=cfg.spreading_factor,
                )
                all_bits.append(bits)
                all_conf.append(conf)

        if not all_bits:
            return [0] * encoded_len, 0.0

        # Majority vote across subbands/levels
        voted = []
        for bit_idx in range(encoded_len):
            votes = [bits[bit_idx] for bits in all_bits if bit_idx < len(bits)]
            if votes:
                voted.append(1 if sum(votes) > len(votes) / 2 else 0)
            else:
                voted.append(0)

        avg_conf = sum(all_conf) / len(all_conf) if all_conf else 0.0
        return voted, avg_conf

    def _decode_combined_payload(
        self, encoded_bits: list[int]
    ) -> tuple[list[int] | None, list[int] | None, list[int] | None, bool]:
        """RS-decode and split the combined payload.

        Returns:
            (beacon_bits, author_index, author_id, rs_success)
        """
        cfg = self.config
        raw_len = cfg.beacon_bits + cfg.author_index_bits + cfg.author_id_bits

        try:
            decoded, num_corrected = decode_payload(
                encoded_bits, nsym=cfg.rs_nsym, original_bit_count=raw_len
            )
            beacon = decoded[: cfg.beacon_bits]
            index = decoded[cfg.beacon_bits : cfg.beacon_bits + cfg.author_index_bits]
            author_id = decoded[cfg.beacon_bits + cfg.author_index_bits :]
            return beacon, index, author_id, True
        except ReedSolomonError:
            return None, None, None, False

    def detect_beacon(self, image: np.ndarray) -> bool:
        """Tier 1: Check if the image contains a Signarture beacon."""
        encoded_bits, conf = self._extract_combined_payload(image)
        beacon, _, _, rs_ok = self._decode_combined_payload(encoded_bits)

        if rs_ok and beacon is not None:
            match_count = sum(1 for b in beacon if b == 1)
            return match_count / self.config.beacon_bits > 0.7

        # RS failed — try raw check on first beacon_bits of the encoded data
        # (won't be accurate but gives a rough signal)
        return False

    def extract_author_index(self, image: np.ndarray) -> list[int] | None:
        """Tier 2: Extract the author index from the combined payload."""
        encoded_bits, conf = self._extract_combined_payload(image)
        _, index, _, rs_ok = self._decode_combined_payload(encoded_bits)
        return index if rs_ok else None

    def _detect_on_image(self, y: np.ndarray, public_key: bytes) -> DetectionResult:
        """Core detection logic on a single grayscale image."""
        cfg = self.config

        # --- Ring detection ---
        # Stable rings: key-derived + sentinel (positions don't depend on image)
        key_radii = derive_ring_radii(public_key, config=cfg)
        sentinel_radii = derive_sentinel_ring_radii(config=cfg)
        stable_radii = np.sort(np.concatenate([key_radii, sentinel_radii]))
        stable_phase = derive_ring_phase_offsets(public_key, len(stable_radii), config=cfg)

        _, ring_confidence = detect_dft_rings(
            y,
            stable_radii,
            tolerance=0.02,
            ring_width=cfg.ring_width,
            phase_offsets=stable_phase,
        )

        # Tampering detection: sentinel rings present but key rings absent
        _, sentinel_conf = detect_dft_rings(
            y,
            sentinel_radii,
            tolerance=0.02,
            ring_width=cfg.ring_width,
        )
        _, key_ring_conf = detect_dft_rings(
            y,
            key_radii,
            tolerance=0.02,
            ring_width=cfg.ring_width,
        )
        tampering_suspected = sentinel_conf > 0.5 and key_ring_conf < 0.2

        # --- Extract combined tiled payload ---
        encoded_bits, tile_conf = self._extract_combined_payload(y)
        beacon_bits, author_index, extracted_id, rs_ok = self._decode_combined_payload(encoded_bits)

        # --- Tier 1: Beacon ---
        if rs_ok and beacon_bits is not None:
            beacon_match = sum(1 for b in beacon_bits if b == 1) / cfg.beacon_bits
            beacon_found = beacon_match > 0.7
        else:
            beacon_found = False

        # --- Tier 3: Author verification ---
        expected_author_id = derive_author_id(public_key, config=cfg)

        if rs_ok and extracted_id is not None:
            errors = sum(a != b for a, b in zip(expected_author_id, extracted_id))
            ber = errors / cfg.author_id_bits
            author_id_match = ber < 0.15
            payload_confidence = 1.0 - ber
        else:
            raw_payload = (
                [1] * cfg.beacon_bits
                + derive_author_index(public_key, config=cfg)
                + derive_author_id(public_key, config=cfg)
            )
            expected_encoded = encode_payload(raw_payload, nsym=cfg.rs_nsym)
            errors = sum(a != b for a, b in zip(expected_encoded, encoded_bits))
            ber = errors / len(expected_encoded)
            author_id_match = ber < 0.25
            payload_confidence = max(0.0, 1.0 - ber)

        # --- Ghost signal analysis ---
        ghost_result = analyze_ghost_signature(y, public_key, cfg)

        # Ghost hash: compare extracted bits with expected
        extracted_ghost_hash = ghost_result.ghost_hash
        expected_ghost_hash = derive_ghost_hash(public_key, cfg)
        if extracted_ghost_hash is not None:
            ghost_hash_errors = sum(
                a != b for a, b in zip(extracted_ghost_hash, expected_ghost_hash)
            )
            ghost_hash_match = ghost_hash_errors <= 2
        else:
            ghost_hash_errors = cfg.ghost_hash_bits
            ghost_hash_match = False

        # Ghost confidence combines correlation strength with hash match quality.
        # The composite PN approach means a wrong key with a similar hash still
        # shows partial correlation, so we scale by hash bit error rate to
        # discriminate authors.
        raw_ghost_conf = min(1.0, max(0.0, ghost_result.correlation / 0.015))
        hash_ber = ghost_hash_errors / max(cfg.ghost_hash_bits, 1)
        ghost_confidence = raw_ghost_conf * max(0.0, 1.0 - hash_ber * 2.0)

        detected = bool(
            payload_confidence > 0.5 and (beacon_found or ring_confidence > 0.5 or author_id_match)
        )

        # Overall confidence: weighted blend of all three layers
        overall_confidence = min(
            1.0, (0.35 * ring_confidence + 0.45 * payload_confidence + 0.20 * ghost_confidence)
        )

        return DetectionResult(
            detected=detected,
            confidence=overall_confidence,
            author_id_match=author_id_match,
            beacon_found=beacon_found,
            author_index=author_index,
            ring_confidence=ring_confidence,
            payload_confidence=payload_confidence,
            ghost_confidence=ghost_confidence,
            ghost_hash=extracted_ghost_hash,
            ghost_hash_match=ghost_hash_match,
            tampering_suspected=tampering_suspected,
        )

    def detect(self, image: np.ndarray, public_key: bytes) -> DetectionResult:
        """Full three-tier detection with a known candidate public key.

        Includes automatic geometric correction: if initial detection is weak,
        tries common rotation corrections and uses the best result.

        Args:
            image: Grayscale (H,W) or RGB (H,W,3) image to check.
            public_key: Candidate author's public key

        Returns:
            DetectionResult with all detection details.
        """
        y = extract_y_channel(image)

        # First attempt: detect without correction
        result = self._detect_on_image(y, public_key)

        # If detection is confident, return immediately
        if result.detected and result.payload_confidence > 0.7:
            return result

        # If ring confidence is high but payload is low, try geometric correction
        if result.ring_confidence > 0.3 and result.payload_confidence < 0.5:

            def conf_fn(img):
                r = self._detect_on_image(img, public_key)
                return r.payload_confidence

            corrected, best_angle, best_conf = try_rotations(y, conf_fn)
            if best_conf > result.payload_confidence:
                result = self._detect_on_image(corrected, public_key)

        return result

detect_beacon(image)

Tier 1: Check if the image contains a Signarture beacon.

Source code in src/sigil_watermark/detect.py
195
196
197
198
199
200
201
202
203
204
205
206
def detect_beacon(self, image: np.ndarray) -> bool:
    """Tier 1: Check if the image contains a Signarture beacon."""
    encoded_bits, conf = self._extract_combined_payload(image)
    beacon, _, _, rs_ok = self._decode_combined_payload(encoded_bits)

    if rs_ok and beacon is not None:
        match_count = sum(1 for b in beacon if b == 1)
        return match_count / self.config.beacon_bits > 0.7

    # RS failed — try raw check on first beacon_bits of the encoded data
    # (won't be accurate but gives a rough signal)
    return False

extract_author_index(image)

Tier 2: Extract the author index from the combined payload.

Source code in src/sigil_watermark/detect.py
208
209
210
211
212
def extract_author_index(self, image: np.ndarray) -> list[int] | None:
    """Tier 2: Extract the author index from the combined payload."""
    encoded_bits, conf = self._extract_combined_payload(image)
    _, index, _, rs_ok = self._decode_combined_payload(encoded_bits)
    return index if rs_ok else None

detect(image, public_key)

Full three-tier detection with a known candidate public key.

Includes automatic geometric correction: if initial detection is weak, tries common rotation corrections and uses the best result.

Parameters:

Name Type Description Default
image ndarray

Grayscale (H,W) or RGB (H,W,3) image to check.

required
public_key bytes

Candidate author's public key

required

Returns:

Type Description
DetectionResult

DetectionResult with all detection details.

Source code in src/sigil_watermark/detect.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def detect(self, image: np.ndarray, public_key: bytes) -> DetectionResult:
    """Full three-tier detection with a known candidate public key.

    Includes automatic geometric correction: if initial detection is weak,
    tries common rotation corrections and uses the best result.

    Args:
        image: Grayscale (H,W) or RGB (H,W,3) image to check.
        public_key: Candidate author's public key

    Returns:
        DetectionResult with all detection details.
    """
    y = extract_y_channel(image)

    # First attempt: detect without correction
    result = self._detect_on_image(y, public_key)

    # If detection is confident, return immediately
    if result.detected and result.payload_confidence > 0.7:
        return result

    # If ring confidence is high but payload is low, try geometric correction
    if result.ring_confidence > 0.3 and result.payload_confidence < 0.5:

        def conf_fn(img):
            r = self._detect_on_image(img, public_key)
            return r.payload_confidence

        corrected, best_angle, best_conf = try_rotations(y, conf_fn)
        if best_conf > result.payload_confidence:
            result = self._detect_on_image(corrected, public_key)

    return result

DetectionResult dataclass

Result of watermark detection.

Contains per-layer confidence scores and the outputs of all three detection tiers: beacon presence, author index extraction, and cryptographic author verification.

Attributes:

Name Type Description
detected bool

True if any watermark was found with sufficient confidence.

confidence float

Overall confidence score (0–1), a weighted blend of ring (35%), payload (45%), and ghost (20%) confidences.

author_id_match bool

True if the extracted author ID matches the provided public key (Tier 3 verification).

beacon_found bool

True if the universal Signarture beacon was detected (Tier 1).

author_index list[int] | None

Extracted 20-bit author index as a list of ints, or None if RS decoding failed (Tier 2).

ring_confidence float

DFT ring detection confidence (0–1).

payload_confidence float

Spread-spectrum payload correlation (0–1).

ghost_confidence float

Ghost signal correlation strength (0–1).

ghost_hash list[int] | None

Extracted ghost hash bits (blind, no key needed), or None if extraction failed.

ghost_hash_match bool

True if the ghost hash matches the candidate key.

tampering_suspected bool

True if sentinel rings are present but key-derived rings have been selectively removed.

Source code in src/sigil_watermark/detect.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@dataclass
class DetectionResult:
    """Result of watermark detection.

    Contains per-layer confidence scores and the outputs of all three
    detection tiers: beacon presence, author index extraction, and
    cryptographic author verification.

    Attributes:
        detected: ``True`` if any watermark was found with sufficient confidence.
        confidence: Overall confidence score (0–1), a weighted blend of
            ring (35%), payload (45%), and ghost (20%) confidences.
        author_id_match: ``True`` if the extracted author ID matches the
            provided public key (Tier 3 verification).
        beacon_found: ``True`` if the universal Signarture beacon was
            detected (Tier 1).
        author_index: Extracted 20-bit author index as a list of ints,
            or ``None`` if RS decoding failed (Tier 2).
        ring_confidence: DFT ring detection confidence (0–1).
        payload_confidence: Spread-spectrum payload correlation (0–1).
        ghost_confidence: Ghost signal correlation strength (0–1).
        ghost_hash: Extracted ghost hash bits (blind, no key needed),
            or ``None`` if extraction failed.
        ghost_hash_match: ``True`` if the ghost hash matches the candidate key.
        tampering_suspected: ``True`` if sentinel rings are present but
            key-derived rings have been selectively removed.
    """

    detected: bool
    confidence: float
    author_id_match: bool
    beacon_found: bool
    author_index: list[int] | None
    ring_confidence: float
    payload_confidence: float
    ghost_confidence: float = 0.0
    ghost_hash: list[int] | None = None
    ghost_hash_match: bool = False
    tampering_suspected: bool = False

SigilConfig dataclass

Immutable configuration for the Sigil watermark system.

Parameters are grouped by layer:

Layer 1 — DFT Ring Anchor: Controls the concentric frequency-domain rings that act as a geometric compass, surviving rotation/scale attacks.

Layer 2 — DWT Spread-Spectrum: Controls the fractal tiling of CDMA-encoded payload (beacon + author index + author ID) into wavelet detail subbands.

Layer 3 — Ghost Signal: Controls the spectral bias signal at VAE-passband frequencies that survives AI training pipelines.

Payload / Crypto / Masking / Quality: Supporting parameters for Reed-Solomon FEC, HKDF key derivation, perceptual masking, and adaptive ring strength.

Example

from sigil_watermark import SigilConfig cfg = SigilConfig(ring_strength=15.0, ghost_strength_multiplier=150.0) cfg.ring_strength 15.0

Source code in src/sigil_watermark/config.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@dataclass(frozen=True)
class SigilConfig:
    """Immutable configuration for the Sigil watermark system.

    Parameters are grouped by layer:

    **Layer 1 — DFT Ring Anchor:** Controls the concentric frequency-domain
    rings that act as a geometric compass, surviving rotation/scale attacks.

    **Layer 2 — DWT Spread-Spectrum:** Controls the fractal tiling of
    CDMA-encoded payload (beacon + author index + author ID) into wavelet
    detail subbands.

    **Layer 3 — Ghost Signal:** Controls the spectral bias signal at
    VAE-passband frequencies that survives AI training pipelines.

    **Payload / Crypto / Masking / Quality:** Supporting parameters for
    Reed-Solomon FEC, HKDF key derivation, perceptual masking, and
    adaptive ring strength.

    Example:
        >>> from sigil_watermark import SigilConfig
        >>> cfg = SigilConfig(ring_strength=15.0, ghost_strength_multiplier=150.0)
        >>> cfg.ring_strength
        15.0
    """

    # --- Layer 1: DFT Ring Anchor ---
    # Number of concentric rings embedded in Fourier magnitude spectrum
    num_rings: int = 4
    # Min/max radius in Fourier space (as fraction of image size / 2)
    # Mid-frequency range: not too fine (lost to blur) nor too coarse (lost to crop)
    ring_radius_min: float = 0.1
    ring_radius_max: float = 0.35
    # Embedding strength for ring peaks (multiplicative magnitude boost).
    # Lower than additive era (was 20) because wider rings + multiplicative
    # scaling adds more total energy.
    ring_strength: float = 20.0
    # Gaussian width of ring profiles (fraction of Nyquist); wider rings
    # force attackers to notch-filter broader bands, increasing quality cost
    ring_width: float = 0.04

    # Number of content-dependent rings (positions derived from image hash + key)
    num_content_rings: int = 2
    # Number of sentinel rings (fixed positions, server-secret-derived)
    num_sentinel_rings: int = 2
    # Sentinel ring secret (in production, loaded from server config, not hardcoded)
    sentinel_secret: bytes = b"signarture-sentinel-v1-secret"
    # HKDF salt for sentinel ring derivation
    sentinel_salt: bytes = b"signarture-sigil-v1-sentinel"
    # HKDF salt for content-dependent ring derivation
    content_ring_salt: bytes = b"signarture-sigil-v1-content-rings"

    # --- Layer 2: Fractal Sigil Tiling ---
    # Tile sizes for multi-scale fractal embedding
    tile_sizes: tuple[int, ...] = (32, 64, 128, 256)
    # DWT wavelet family
    wavelet: str = "db4"
    # DWT decomposition levels for embedding
    dwt_levels: int = 3
    # Subbands to embed in (HL=horizontal detail, LH=vertical detail)
    embed_subbands: tuple[str, ...] = ("LH", "HL")
    # Base embedding strength (scaled by perceptual mask)
    embed_strength: float = 3.0
    # CDMA spreading factor (chips per payload bit)
    spreading_factor: int = 256

    # --- Layer 3: Training Ghost Signal ---
    # Frequency bands selected for high VAE survival (empirically measured
    # via scripts/vae_passband_analysis.py against stabilityai/sd-vae-ft-mse).
    # Survival ratios: 0.343→55×, 0.425→26×, 0.218→24×, 0.163→14×, 0.286→9×
    ghost_bands: tuple[float, ...] = (0.163, 0.218, 0.286, 0.343, 0.425)
    # Bandwidth of each ghost band (fraction of Nyquist)
    ghost_bandwidth: float = 0.05
    # Ghost signal strength multiplier (relative to embed_strength)
    # Sweep showed no quality impact up to 200× (PSNR stays >46.9dB, SSIM >0.994).
    # At 100× with VAE-optimized bands, single-image ghost detection hits 100%
    # after real SD VAE encode/decode.
    ghost_strength_multiplier: float = 200.0
    # Number of ghost hash bits for author binning (2^N bins for O(K) lookup).
    # Per-bit SNR drops by 1/sqrt(N) — 8 bits balances robustness (survives VAE)
    # with useful binning (256 bins → ~39 candidates for 10K artists).
    ghost_hash_bits: int = 8

    # --- Payload ---
    # Author ID size in bits
    author_id_bits: int = 48
    # Beacon size in bits (universal Signarture marker)
    beacon_bits: int = 8
    # Author index size in bits (for blind scanning)
    author_index_bits: int = 20
    # Reed-Solomon error correction symbols for author index
    rs_nsym: int = 8

    # --- Crypto ---
    # HKDF salt for PN sequence derivation
    pn_salt: bytes = b"signarture-sigil-v1-pn"
    # HKDF salt for ring parameter derivation
    ring_salt: bytes = b"signarture-sigil-v1-rings"
    # HKDF salt for universal beacon
    beacon_salt: bytes = b"signarture-sigil-v1-beacon"
    # Universal beacon seed (fixed, public)
    beacon_seed: bytes = b"signarture-universal-beacon-v1"
    # Universal PN seed for author index tier
    universal_pn_seed: bytes = b"signarture-universal-pn-v1"

    # --- Perceptual Masking ---
    # Minimum embedding strength (even in flat regions)
    mask_floor: float = 0.3
    # Noise sensitivity threshold for JND
    jnd_threshold: float = 3.0

    # --- Adaptive ring strength ---
    # When enabled, ring embedding alpha is scaled down on images with strong
    # spectral energy at ring frequencies so the ring layer alone stays above
    # ring_target_psnr.  Uses Parseval's theorem for exact MSE prediction.
    adaptive_ring_strength: bool = True
    # Target PSNR for the ring layer alone (dB). Since DWT + ghost add more
    # distortion, overall PSNR will be ~2-4 dB lower.
    ring_target_psnr: float = 36.0
    # Floor: alpha never drops below this fraction of the nominal value,
    # ensuring ring detection robustness even on extreme images.
    # 0.30 = ring_conf ≥ 0.16 on all tested real photos while achieving
    # avg 40 dB PSNR (vs 30 dB without adaptation).
    ring_min_alpha_fraction: float = 0.30

    # --- Quality targets ---
    target_psnr_db: float = 40.0
    target_ssim: float = 0.98

AuthorKeys dataclass

Ed25519 keypair for a watermark author.

Attributes:

Name Type Description
private_key bytes

32-byte raw Ed25519 private key seed.

public_key bytes

32-byte raw Ed25519 public key, used to derive all watermark parameters (ring positions, PN sequences, author ID).

Source code in src/sigil_watermark/keygen.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass
class AuthorKeys:
    """Ed25519 keypair for a watermark author.

    Attributes:
        private_key: 32-byte raw Ed25519 private key seed.
        public_key: 32-byte raw Ed25519 public key, used to derive all
            watermark parameters (ring positions, PN sequences, author ID).
    """

    private_key: bytes
    public_key: bytes

    @classmethod
    def from_private_key(cls, private_key_bytes: bytes) -> AuthorKeys:
        priv = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
        pub = priv.public_key()
        pub_bytes = pub.public_bytes(
            serialization.Encoding.Raw,
            serialization.PublicFormat.Raw,
        )
        return cls(private_key=private_key_bytes, public_key=pub_bytes)

generate_author_keys(seed=None)

Generate a new Ed25519 keypair for watermark embedding.

Parameters:

Name Type Description Default
seed bytes | None

Optional seed bytes for deterministic key generation. If None, a cryptographically random key is generated. Useful for reproducible testing.

None

Returns:

Name Type Description
An AuthorKeys

class:AuthorKeys instance with both private and public keys.

Example

keys = generate_author_keys(seed=b"my-secret-seed") len(keys.public_key) 32

Source code in src/sigil_watermark/keygen.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def generate_author_keys(seed: bytes | None = None) -> AuthorKeys:
    """Generate a new Ed25519 keypair for watermark embedding.

    Args:
        seed: Optional seed bytes for deterministic key generation.
            If ``None``, a cryptographically random key is generated.
            Useful for reproducible testing.

    Returns:
        An :class:`AuthorKeys` instance with both private and public keys.

    Example:
        >>> keys = generate_author_keys(seed=b"my-secret-seed")
        >>> len(keys.public_key)
        32
    """
    if seed is not None:
        # Use HKDF to derive a 32-byte key seed from arbitrary-length seed
        hkdf = HKDF(
            algorithm=hashes.SHA256(),
            length=32,
            salt=b"signarture-keygen-v1",
            info=b"ed25519-seed",
        )
        derived = hkdf.derive(seed)
        priv = Ed25519PrivateKey.from_private_bytes(derived)
    else:
        priv = Ed25519PrivateKey.generate()

    priv_bytes = priv.private_bytes(
        serialization.Encoding.Raw,
        serialization.PrivateFormat.Raw,
        serialization.NoEncryption(),
    )
    pub_bytes = priv.public_key().public_bytes(
        serialization.Encoding.Raw,
        serialization.PublicFormat.Raw,
    )
    return AuthorKeys(private_key=priv_bytes, public_key=pub_bytes)