DEV Community

Cover image for The file is called encryption.py. It is actually an invisible watermark!
Wlad Radchenko
Wlad Radchenko

Posted on

The file is called encryption.py. It is actually an invisible watermark!

There is a file in my app called encryption.py. The class is EncryptionEncoder. The methods on the video manager are encrypted() and decrypted(). Every label says crypto.

It is not crypto. There is no cipher in that file, no key, no AES, no Fernet. What it actually does is hide a short text string inside the pixels of a frame so you cannot see it but a decoder can read it back. That is a watermark. The naming is just unfortunate, and I am going to use the honest word for the rest of this post.

I want to walk through the code, because the mechanism is small and clever, and then be straight about what a thing like this can and cannot do when it ships inside a fully local app that the user owns.

Source code on a screen
The name on the file says encryption. The code says watermark. Photo: Unsplash.

What problem this solves

The app generates synthetic media. Face swaps, lip-sync, retargeted portraits. At some point you want to answer one question about a file: did this come out of my tool, or is it an untouched original?

The honest way to answer that without a visible logo is to embed a marker the eye does not notice and a program can find. So when the app finishes a video, it writes a hidden string into a corner of each frame. Later, an analysis step reads the corners and checks for that string.

Here is the read side, from inference.py:

is_original = VideoManipulation.decrypted(source_file)
if is_original:
    lprint(msg="Content is original.", ...)
else:
    lprint(msg="Content is Wunjo fake.", ...)
Enter fullscreen mode Exit fullscreen mode

So the whole point is a yes/no flag: is this our synthetic output. The marker string in the code is literally name="fake". That is the payload it stamps and the payload it looks for.

How the marker gets into a frame

The payload is just bytes. EncryptionEncoder turns a UTF-8 string into a flat list of bits:

seq = np.array([n for n in content], dtype=np.uint8)
self._encryptions = list(np.unpackbits(seq))   # e.g. b"fake" -> 32 bits
self._wmLen = len(self._encryptions)
Enter fullscreen mode Exit fullscreen mode

Four ASCII characters give 32 bits. That 32 matters later, the decoder is hard-coded to expect 32-bit payloads.

The default embedding method in the app is dwtDctSvd. Three transforms stacked, and each one earns its place. Walk it from the outside in.

Step one, color space. Convert the patch from BGR to YUV and only touch the Y (luma) and U channels:

yuv = cv2.cvtColor(bgr, cv2.COLOR_BGR2YUV)
for channel in range(2):   # only Y and U
    ...
Enter fullscreen mode Exit fullscreen mode

Luma and chroma are where you can nudge values without the change jumping out at a viewer. The V channel is left alone.

Step two, wavelet. Run a single-level Haar dwt2 on the channel. This splits the image into a coarse approximation ca1 and three detail bands:

ca1, (h1, v1, d1) = pywt.dwt2(yuv[:row//4*4, :col//4*4, channel], 'haar')
Enter fullscreen mode Exit fullscreen mode

Everything gets embedded into ca1, the coarse band. Coarse coefficients survive compression and resizing far better than fine detail, which is the first reason this watermark is hard to wipe by accident.

Step three, the actual bit. Chop ca1 into 4x4 blocks. For each block, take a DCT, then an SVD, and quantize the top singular value:

def diffuse_dct_svd(self, block, wmBit, scale):
    u, s, v = np.linalg.svd(cv2.dct(block))
    s[0] = (s[0] // scale + 0.25 + 0.5 * wmBit) * scale
    return cv2.idct(np.dot(u, np.dot(np.diag(s), v)))
Enter fullscreen mode Exit fullscreen mode

That one line is the trick. Read it slowly.

scale is 36. s[0] is the largest singular value of the block, the bulk of its energy. s[0] // scale snaps it down to a multiple of 36. Then you add back a fixed offset that depends only on the bit you want to store: 0.25 * scale for a 0, 0.75 * scale for a 1. So after this, every block sits at either "a quarter of the way into its 36-wide bin" or "three quarters of the way in," and which one it is encodes the bit.

Decoding just asks where in the bin the value landed:

score = int((s[0] % scale) > scale * 0.5)
Enter fullscreen mode Exit fullscreen mode

Below the halfway mark reads 0, above reads 1. The 0.25 and 0.75 placement is deliberate, it parks each bit as far from the decision boundary as possible so JPEG noise has to be large to flip it.

The part that makes it survive: repeat and average

A 32-bit payload does not need many blocks. A 256x256 patch gives you hundreds of 4x4 blocks. So the encoder writes the same 32 bits over and over:

wmBit = self._encryptions[(num % self._wmLen)]   # cycle through the 32 bits
Enter fullscreen mode Exit fullscreen mode

Block 0 holds bit 0, block 32 holds bit 0 again, and so on. Every bit is stamped dozens of times across the patch.

The decoder collects all the readings for each bit position and takes the mean before thresholding:

scores = [[] for i in range(self._wmLen)]
# ... append a score per block to scores[num % wmLen] ...
avgScores = list(map(lambda l: np.array(l).mean(), scores))
bits = (np.array(avgScores) * 255 > 127)
Enter fullscreen mode Exit fullscreen mode

This is the cheap, effective version of error correction. Compression might flip a handful of blocks, but the average over a hundred copies of the same bit barely moves. That is why the marker survives a save-and-reopen.

Where it gets written, and the skip check

The app does not watermark the whole frame. It picks a 256x256 box, usually a corner or the center, and stamps only that:

positions = [
    (0, box_size, 0, box_size),                       # top-left
    (0, box_size, int(width - box_size), width),      # top-right
    (int(height - box_size), height, 0, box_size),    # bottom-left
    (int(height - box_size), height, int(width - box_size), width),
    # ...center, plus some (None, None, None, None) no-op slots
]
y1, y2, x1, x2 = random.sample(positions, 1)[0]
Enter fullscreen mode Exit fullscreen mode

Note the None slots. On video, some frames get no watermark at all, picked at random. That spreads the marker across frames instead of branding every single one, which keeps any per-frame artifact from accumulating into something visible.

Before stamping, the writer decodes first and skips if the marker is already there:

decoded_text = decoder.decode(bgr, 'dwtDctSvd').decode('utf-8')
if decoded_text != name and width >= box_size and height >= box_size:
    # encode
Enter fullscreen mode Exit fullscreen mode

So re-running the tool on its own output does not double-stamp. And the 256x256 minimum is enforced in the encoder too, it raises if r*c < 256*256. Below that you do not have enough blocks for the repeat-and-average to be reliable.

On the read side, decrypted() checks the four corners of each frame and returns False (not original) the moment any corner decodes to the marker. One hit is enough.

Gotchas you will actually hit

A few things bit me, and will bit you if you reuse this code:

  • The payload length is wired to 32 bits. EncryptionDecoder('bytes', 32) everywhere. Your marker string has to be exactly four bytes, or you change the length in both encoder and decoder. set_by_ipv4 even asserts self._wmLen == 32.
  • Lossless transcode matters. The video is reassembled with ffmpeg ... -c:v copy, a stream copy, no re-encode. If you instead re-encode at a low bitrate, you are testing how much the watermark survives, and the answer depends on scale. A bigger scale survives harder compression but distorts the patch more.
  • The rivaGan method needs ONNX models. The third method loads encoder.onnx and decoder.onnx from the module directory and even runs chmod/icacls on them to fix file permissions. The app does not use this path by default; dwtDctSvd is what videoio.py calls.
  • There is dead code. infer_dct_svd has a return score followed by an unreachable if score >= 0.5. Do not be confused by it, the function returns the int and exits.

The honest part: this is obfuscation, not protection

Now the uncomfortable bit. This ships inside a fully local desktop app. Everything is on the user's machine: the encoder, the decoder, and the marker string fake, all in plain Python in an open-source repo.

So be clear about what that means:

  • There is no secret. Anyone who reads the repo knows the algorithm, the scale, and the payload. They can read the marker, strip it, or forge it onto an untouched file.
  • It is not DRM. It does not stop anyone from using, copying, or editing the output. It does not lock anything.
  • It is not a cipher, despite the filename. A cipher needs a key the attacker does not have. A local app has no place to hide a key from its own user, so this scheme does not try.

What it actually buys you is provenance under cooperative conditions. If a file passes through normal handling, a re-save, a crop that leaves a corner intact, a platform transcode, the marker is likely still readable, and the app can honestly say "this came out of me." That is genuinely useful for a tool that generates synthetic media and wants to flag its own output. It is a fragile-but-invisible label, not a lock.

The threat it addresses is accidental loss of provenance, not a motivated adversary. Knowing which threat you are defending against is the whole game. Calling it encryption oversells it. Calling it a watermark, and being clear that the watermark is removable by anyone who cares, is the truth.


Wlad Radchenko About the author. I'm Wlad Radchenko, a software engineer. The code in this article comes from Wunjo Make (open source), local software for video makers, and Wunjo Design, an offline PWA for designers. Get in touch to find more on GitHub and LinkedIn.

Wrap-up

The mechanism is one quantized singular value per 4x4 block, the same 32 bits repeated across a 256x256 patch, decoded by averaging. Small, hard to wipe by ordinary handling, and completely open. If you want to read the whole thing, it is portable/src/visual_processing/utils/encryption.py in the repo, with the call site in videoio.py.

If you take one thing away: name your modules for what they do. A watermark called encryption.py will make every future reader, including you, reason about a threat model that the code never had.

Top comments (0)