diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f965a18
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+resources/models/**/*.onnx filter=lfs diff=lfs merge=lfs -text
+artifacts/promoted/**/*.onnx filter=lfs diff=lfs merge=lfs -text
+artifacts/promoted/**/*.ckpt filter=lfs diff=lfs merge=lfs -text
+artifacts/promoted/**/*.safetensors filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/python-engine.yml b/.github/workflows/python-engine.yml
index 900b090..e9fd5ac 100644
--- a/.github/workflows/python-engine.yml
+++ b/.github/workflows/python-engine.yml
@@ -6,61 +6,76 @@ permissions:
contents: read
jobs:
- build:
+ python-smoke:
runs-on: ubuntu-latest
name: Test robust watermark engine
defaults:
run:
working-directory: blind_watermark
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install dependencies
run: |
pip install -r requirements.txt
- - name: Smoke test — import and roundtrip
+ - name: Smoke test - import and roundtrip
shell: python {0}
env:
PYTHONPATH: .
run: |
from rwm_engine import embed_watermark, extract_watermark, check_dependencies
import numpy as np
+
dep = check_dependencies()
assert dep['ok'], f'Missing deps: {dep}'
+
img = np.random.randint(0, 256, (800, 800, 3), dtype=np.uint8)
- wm = embed_watermark(img, 'hello world', password=42, quality='balanced')
- extracted = extract_watermark(wm, password=42, quality='balanced')
- assert extracted == 'hello world', f'Roundtrip failed: {extracted}'
+
+ embedded = embed_watermark(
+ img,
+ 'hello world',
+ password=42,
+ quality='balanced',
+ )
+ assert embedded['ok'], f'Embed failed: {embedded}'
+ assert embedded['image'] is not None, f'Embed image missing: {embedded}'
+ assert embedded['engine_used'] in {'legacy', 'neural'}, embedded
+
+ extracted = extract_watermark(
+ embedded['image'],
+ password=42,
+ quality='balanced',
+ )
+ assert extracted['ok'], f'Extract failed: {extracted}'
+ assert extracted['wm'] == 'hello world', f'Roundtrip failed: {extracted}'
+
print('All tests passed.')
mlwm-unit-tests:
runs-on: ubuntu-latest
name: MLWM unit tests
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
with:
python-version: '3.10'
- - name: Run MLWM unit tests when present
- shell: bash
+ - name: Install dependencies
+ run: |
+ pip install -r blind_watermark/requirements.txt
+ - name: Run MLWM unit tests
run: |
- if [ -d blind_watermark/tests ]; then
- pip install -r blind_watermark/requirements.txt
- python -m unittest discover -s blind_watermark/tests
- else
- echo "No blind_watermark/tests directory exists on this branch yet."
- fi
+ python -m unittest discover -s blind_watermark/tests
typecheck:
runs-on: ubuntu-latest
name: Typecheck
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
with:
- node-version: '20'
+ node-version: '24'
cache: npm
- name: Install dependencies
run: npm ci
diff --git a/.gitignore b/.gitignore
index d29827e..f710dbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ out
.DS_Store
.eslintcache
*.log*
+
# PyInstaller output (pre-built bwm_helper executable, generated via npm run build:python)
resources/bin/
build/
@@ -28,12 +29,27 @@ __pycache__/
*.pyd
.pytest_cache/
.mypy_cache/
+.venv*/
+.ruff_cache/
+.hypothesis/
# Build/cache artifacts
coverage/
*.tsbuildinfo
tmp/
temp/
+artifacts/mlwm_v1/runs/
+artifacts/mlwm_v1/tensorboard/
+artifacts/mlwm_v1/tmp/
+artifacts/promoted/**/*.tmp
+artifacts/promoted/**/*.log
+resources/models/neural_wm/*.tmp
+resources/models/neural_wm/*.pt
+resources/models/neural_wm/*.ckpt
+resources/models/neural_wm/*.safetensors
+resources/models/neural_wm/*.bin
+data/
+datasets/
# Test images
-blind_watermark/测试图*.png
+blind_watermark/濞村鐦崶?.png
diff --git a/README.md b/README.md
index 2096b41..d8943a3 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,21 @@ LuminCrypt is a desktop security toolkit for **Unicode hidden-character detectio
- **Unicode hidden character detection**: finds zero-width characters, BiDi controls, homoglyphs, Unicode Tags, variation selectors, and non-standard spaces.
- **Encrypted text watermarking**: embeds AES-256-GCM protected payloads into normal text with invisible Unicode carriers and robust redundancy.
- **Blind image watermarking**: uses a Python image watermark engine based on block-DCT, QIM-style embedding, Reed-Solomon recovery, and multi-scale extraction.
+- **Learning-assisted robust watermarking**: includes an experimental MLWM v1 alpha engine for short image payloads, ONNX inference, attack simulation, benchmark manifests, and automatic fallback to the legacy image watermark engine.
- **Batch processing and reports**: scans files in batches and exports detection results as JSON, CSV, or PDF.
- **Local desktop workflow**: built with Electron, React, TypeScript, and a Python helper for image watermark processing.
+## Project Status
+
+| Area | Status |
+|---|---|
+| Unicode hidden-character detection | Usable |
+| Encrypted text watermarking | Usable |
+| Legacy image blind watermarking | Usable |
+| MLWM v1 neural image watermarking | Alpha, short payloads only |
+
+`mlwm-v1-alpha1` is the first promoted neural watermark candidate. It is suitable for internal alpha testing and controlled validation, not yet for unsupported industrial deployment claims.
+
## Screenshots
@@ -52,7 +64,7 @@ LuminCrypt is a desktop security toolkit for **Unicode hidden-character detectio
|---|---:|---|
| Node.js | 18+ | Electron and frontend build |
| npm | 9+ | Package manager |
-| Python | 3.10+ recommended | Required for the image watermark backend |
+| Python | 3.10+ runtime, 3.12 recommended for ML | Required for the image watermark backend and ML training tools |
## Quick Start
@@ -80,6 +92,18 @@ Run TypeScript checks:
npm run typecheck
```
+Run Python image-watermark tests:
+
+```bash
+python -m unittest discover -s blind_watermark/tests
+```
+
+Install ML training dependencies only when you need to train or export candidate neural models:
+
+```bash
+pip install -r blind_watermark/requirements-ml.txt
+```
+
## Build
```bash
@@ -119,10 +143,25 @@ LuminCrypt/
| `-- components/ # React components
|-- blind_watermark/
| |-- rwm_engine.py # Image blind watermark engine
-| `-- bwm_helper.py # CLI bridge used by Electron
-`-- resources/ # Static assets and packaged binaries
+| |-- bwm_helper.py # CLI bridge used by Electron
+| |-- mlwm/ # MLWM neural watermark training, export, and inference modules
+| `-- tests/ # Python unit tests
+|-- configs/mlwm/ # MLWM training, export, and evaluation configs
+|-- docs/mlwm/ # MLWM architecture, training, and traceability docs
+`-- resources/ # Static assets, packaged binaries, and promoted ONNX models
```
+## MLWM v1 Alpha
+
+MLWM v1 is a learning-assisted robust image watermark path for short text or ID payloads. It combines deterministic payload framing, CRC and Reed-Solomon recovery, classical synchronization ideas, lightweight PyTorch encoder/decoder models, and ONNX Runtime inference for desktop use.
+
+Useful references:
+
+- [MLWM Architecture](docs/mlwm/architecture.md)
+- [MLWM Training](docs/mlwm/training.md)
+- [MLWM Traceability](docs/mlwm/traceability.md)
+- [Benchmark Protocol](docs/mlwm/benchmark_protocol.md)
+
## Search Keywords
Unicode steganography, Unicode watermark, invisible watermark, AI watermark detection, zero-width character detector, homoglyph detection, BiDi control detector, text watermarking, blind image watermark, robust image watermarking, digital watermarking, image forensics, content provenance, Electron security tool.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 86ecbba..a962a60 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -26,9 +26,21 @@ LuminCrypt 是一款桌面安全工具箱,面向 **Unicode 隐藏字符检测*
- **Unicode 隐藏字符检测**:识别零宽字符、BiDi 控制符、同形字符、Unicode Tags、变体选择器和特殊空格。
- **加密文本水印**:使用 AES-256-GCM 保护载荷,并通过不可见 Unicode 字符写入普通文本,支持鲁棒冗余。
- **图片盲水印**:使用 Python 图片水印引擎,基于 Block-DCT、QIM 风格嵌入、Reed-Solomon 恢复和多尺度提取。
+- **学习型鲁棒图片水印**:提供实验性的 MLWM v1 alpha 引擎,支持短载荷图片水印、ONNX 推理、攻击模拟、评测 manifest,并可自动回退到 legacy 图片水印引擎。
- **批量处理和报告导出**:支持批量扫描,并可导出 JSON、CSV 或 PDF 检测结果。
- **本地桌面流程**:使用 Electron、React、TypeScript 构建,图片水印能力由 Python helper 提供。
+## 项目状态
+
+| 模块 | 状态 |
+|---|---|
+| Unicode 隐藏字符检测 | 可用 |
+| 加密文本水印 | 可用 |
+| Legacy 图片盲水印 | 可用 |
+| MLWM v1 神经网络图片水印 | Alpha,仅支持短载荷 |
+
+`mlwm-v1-alpha1` 是第一个已提升的神经网络图片水印候选模型,适合内部 alpha 测试和可控验证;暂不建议直接宣称为无需边界说明的产业级能力。
+
## 截图
@@ -52,7 +64,7 @@ LuminCrypt 是一款桌面安全工具箱,面向 **Unicode 隐藏字符检测*
|---|---:|---|
| Node.js | 18+ | Electron 和前端构建 |
| npm | 9+ | 包管理 |
-| Python | 推荐 3.10+ | 图片水印后端需要 |
+| Python | 运行时 3.10+,ML 训练推荐 3.12 | 图片水印后端和 ML 训练工具需要 |
## 快速开始
@@ -80,6 +92,18 @@ npm run dev
npm run typecheck
```
+运行 Python 图片水印测试:
+
+```bash
+python -m unittest discover -s blind_watermark/tests
+```
+
+只有在训练或导出神经网络候选模型时,才需要安装 ML 训练依赖:
+
+```bash
+pip install -r blind_watermark/requirements-ml.txt
+```
+
## 构建打包
```bash
@@ -119,10 +143,25 @@ LuminCrypt/
| `-- components/ # React 组件
|-- blind_watermark/
| |-- rwm_engine.py # 图片盲水印引擎
-| `-- bwm_helper.py # Electron 调用的 CLI 桥接
-`-- resources/ # 静态资源和打包二进制
+| |-- bwm_helper.py # Electron 调用的 CLI 桥接
+| |-- mlwm/ # MLWM 神经网络水印训练、导出和推理模块
+| `-- tests/ # Python 单元测试
+|-- configs/mlwm/ # MLWM 训练、导出和评测配置
+|-- docs/mlwm/ # MLWM 架构、训练和可追溯文档
+`-- resources/ # 静态资源、打包二进制和已提升的 ONNX 模型
```
+## MLWM v1 Alpha
+
+MLWM v1 是面向短文本或 ID 载荷的学习型鲁棒图片水印链路。它结合了固定 payload 协议、CRC 与 Reed-Solomon 恢复、经典同步思路、轻量 PyTorch 编码器/解码器,以及桌面端 ONNX Runtime 推理。
+
+相关文档:
+
+- [MLWM 架构](docs/mlwm/architecture.md)
+- [MLWM 训练](docs/mlwm/training.md)
+- [MLWM 可追溯方案](docs/mlwm/traceability.md)
+- [Benchmark 协议](docs/mlwm/benchmark_protocol.md)
+
## 关键词
Unicode 隐写、Unicode 水印、不可见水印、AI 水印检测、零宽字符检测、同形字符检测、BiDi 控制符检测、文本水印、图片盲水印、鲁棒图片水印、数字水印、图片取证、内容溯源、Electron 安全工具。
diff --git a/artifacts/promoted/mlwm-v1-alpha1/benchmark.json b/artifacts/promoted/mlwm-v1-alpha1/benchmark.json
new file mode 100644
index 0000000..5baddc2
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/benchmark.json
@@ -0,0 +1,71 @@
+{
+ "createdAt": "2026-04-27T04:10:42+00:00",
+ "validationSamples": 1497,
+ "evaluations": {
+ "clean": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "clean",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 1.0,
+ "exactMatch": 1.0,
+ "decodeSuccess": 1.0,
+ "createdAt": "2026-04-27T04:07:27+00:00"
+ },
+ "medium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9989802042643229,
+ "exactMatch": 0.9641927083333334,
+ "decodeSuccess": 0.9921875,
+ "createdAt": "2026-04-27T04:07:53+00:00"
+ },
+ "hard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9915457831488715,
+ "exactMatch": 0.824001736111111,
+ "decodeSuccess": 0.912326388888889,
+ "createdAt": "2026-04-27T04:08:22+00:00"
+ },
+ "fullMedium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9992650349934896,
+ "exactMatch": 0.9615885416666666,
+ "decodeSuccess": 0.9895833333333334,
+ "createdAt": "2026-04-27T04:10:09+00:00"
+ },
+ "fullHard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9978663126627604,
+ "exactMatch": 0.9231770833333334,
+ "decodeSuccess": 0.9772135416666666,
+ "createdAt": "2026-04-27T04:10:42+00:00"
+ }
+ }
+}
\ No newline at end of file
diff --git a/artifacts/promoted/mlwm-v1-alpha1/best.ckpt b/artifacts/promoted/mlwm-v1-alpha1/best.ckpt
new file mode 100644
index 0000000..cddfaa3
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/best.ckpt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d752bcc4708e7b74e661bafe2b95e4ac4f6d2e04da6edca918f49ac56792d9b5
+size 8086229
diff --git a/artifacts/promoted/mlwm-v1-alpha1/eval_full_attack.yaml b/artifacts/promoted/mlwm-v1-alpha1/eval_full_attack.yaml
new file mode 100644
index 0000000..bb1296b
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/eval_full_attack.yaml
@@ -0,0 +1,102 @@
+seed: 20260426
+image_size: 448
+batch_size: 8
+grad_accum: 2
+num_workers: 12
+device: cuda
+amp: true
+max_text_bytes: 16
+payload_bits: 256
+train_dir: data/train_images
+val_dir: data/val_images
+artifacts_dir: artifacts/mlwm_v1
+promoted_dir: artifacts/promoted
+models_dir: resources/models/neural_wm
+optimizer:
+ lr: 4.0e-05
+ weight_decay: 1.0e-05
+loss:
+ payload_weight: 10.0
+ image_weight: 0.6
+ tv_weight: 0.01
+ residual_weight: 0.02
+stages:
+ warmup:
+ enabled: false
+ epochs: 3
+ freeze_encoder: true
+ fixed_residual_scale: 0.04
+ attack_strength: medium
+ main:
+ enabled: false
+ epochs: 12
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: true
+ epochs: 18
+ freeze_encoder: true
+ attack_strength: medium
+ freeze_decoder: false
+ encoder_teacher_weight: 150.0
+ encoder_teacher_scale: 0.034
+ loss:
+ payload_weight: 14.0
+ image_weight: 0.0
+ tv_weight: 0.0
+ residual_weight: 0.0
+ use_fixed_residual: false
+ finalize:
+ enabled: true
+ epochs: 8
+ freeze_encoder: false
+ attack_strength: medium
+ freeze_decoder: false
+ loss:
+ payload_weight: 12.0
+ image_weight: 0.65
+ tv_weight: 0.018
+ residual_weight: 0.025
+attack:
+ ops_per_sample:
+ min: 1
+ max: 2
+ jpeg_quality:
+ - 75
+ - 95
+ webp_quality:
+ - 75
+ - 95
+ resize_scale:
+ - 0.85
+ - 1.15
+ crop_keep:
+ - 0.96
+ - 1.0
+ rotation_deg:
+ - -1.0
+ - 1.0
+ perspective_ratio:
+ - 0.0
+ - 0.01
+ gaussian_blur_sigma:
+ - 0.0
+ - 0.35
+ motion_blur_ksize:
+ - 3
+ - 5
+ gaussian_noise_std:
+ - 0.0
+ - 0.004
+ overlay_area:
+ - 0.01
+ - 0.04
+ overlay_alpha:
+ - 0.1
+ - 0.25
+model:
+ residual_scale: 0.058823529411764705
+dashboard:
+ status_interval_batches: 1
+ sample_interval_batches: 10
+checkpoint: artifacts/mlwm_v1/runs/20260426T174908+0000_e7ba793/best.ckpt
diff --git a/artifacts/promoted/mlwm-v1-alpha1/metrics_epoch.csv b/artifacts/promoted/mlwm-v1-alpha1/metrics_epoch.csv
new file mode 100644
index 0000000..7c36145
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/metrics_epoch.csv
@@ -0,0 +1,26 @@
+epoch,stage,train_loss,val_payload_acc,val_exact_match,val_decode_success,val_confidence
+1,hard_negative,0.04573048144405491,0.9969411375661376,0.9252645502645502,0.9748677248677249,0.9964011065543644
+2,hard_negative,0.04366658433006159,0.9984886532738095,0.9484126984126984,0.9920634920634921,0.9955250781679911
+3,hard_negative,0.04120893713604253,0.9984343998015873,0.9484126984126984,0.9821428571428571,0.995825469493866
+4,hard_negative,0.04206904746458354,0.9988813450727514,0.9292328042328042,0.9880952380952381,0.9968866999187167
+5,hard_negative,0.033198884960146446,0.9980856274801587,0.9444444444444444,0.9801587301587301,0.9973790721287803
+6,hard_negative,0.039924131055661366,0.9991784474206349,0.9523809523809523,0.9880952380952381,0.9982844401919653
+7,hard_negative,0.03050802234401075,0.9990079365079365,0.9543650793650794,0.9940476190476191,0.9980981936530461
+8,hard_negative,0.037945131809504144,0.9988064236111112,0.9503968253968254,0.9940476190476191,0.9973026154533265
+9,hard_negative,0.034928924263667456,0.9984654017857143,0.9682539682539683,0.9900793650793651,0.996932998536125
+10,hard_negative,0.03208624450338653,0.9985816592261905,0.9464285714285714,0.9861111111111112,0.996999358373975
+11,hard_negative,0.02782364934266197,0.9987754216269841,0.9662698412698413,0.9940476190476191,0.9967201749483744
+12,hard_negative,0.03180287201591232,0.9986979166666666,0.9623015873015873,0.9841269841269841,0.9971964699881417
+13,hard_negative,0.028288940866040453,0.9991784474206349,0.9563492063492064,0.996031746031746,0.9974935612981282
+14,hard_negative,0.03839817073253836,0.9976515997023809,0.9464285714285714,0.9761904761904762,0.9963689068007091
+15,hard_negative,0.031832219172813404,0.9983801463293651,0.9689153439153438,0.9847883597883598,0.9973160879952567
+16,hard_negative,0.03439752528616459,0.9992171999007936,0.9623015873015873,0.9900793650793651,0.9973890128589812
+17,hard_negative,0.025241811088225617,0.9988451760912699,0.9603174603174603,0.9920634920634921,0.9963471104228308
+18,hard_negative,0.027990431887515124,0.9993489583333334,0.9722222222222222,0.996031746031746,0.9977506436998882
+19,finalize,0.06913595193409346,0.9985584077380952,0.9523809523809523,0.9920634920634921,0.9966789077198694
+20,finalize,0.07437173696151816,0.9976593501984127,0.9292328042328042,0.9828042328042328,0.9965127025331769
+21,finalize,0.06458439488368982,0.9977988591269841,0.9305555555555556,0.9821428571428571,0.9975346516049097
+22,finalize,0.07922951265200043,0.9988994295634921,0.9226190476190477,0.9880952380952381,0.9969448740520175
+23,finalize,0.07797464108137736,0.9987289186507936,0.9384920634920635,0.9880952380952381,0.9966847508672684
+24,finalize,0.09515007382753486,0.9977678571428571,0.9226190476190477,0.9761904761904762,0.9962382089524042
+25,finalize,0.09965920207919071,0.9975301752645502,0.9153439153439153,0.9808201058201058,0.9962732158009968
diff --git a/artifacts/promoted/mlwm-v1-alpha1/run_manifest.json b/artifacts/promoted/mlwm-v1-alpha1/run_manifest.json
new file mode 100644
index 0000000..67a0d4d
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/run_manifest.json
@@ -0,0 +1,104 @@
+{
+ "runId": "20260426T185428+0000_6107b38",
+ "promoted": true,
+ "promotionId": "mlwm-v1-alpha1",
+ "gitBranch": "codex-mlwm-v1",
+ "trainingGitCommit": "6107b38",
+ "exportGitCommit": "eae79611614f5cdde7958d1fd773010ca46cdd23",
+ "pythonVersion": "3.12.3",
+ "gpu": "NVIDIA GeForce RTX 5090, 580.76.05, 32607 MiB",
+ "trainConfig": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "trainConfigSha256": "ac7057e92246bf344acdf0d059c14427ed64cb6eac4252a3ba9a9ee72da984ab",
+ "datasetManifestHash": "66e398a984115421eb783c1bfeaa61140be7d0e482ef7850fcaabd3ec22c1136",
+ "checkpoint": {
+ "path": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "sha256": "d752bcc4708e7b74e661bafe2b95e4ac4f6d2e04da6edca918f49ac56792d9b5",
+ "bestEpoch": 18,
+ "bestMetric": 2.9676029265873014
+ },
+ "onnx": {
+ "encoder": {
+ "path": "encoder.onnx",
+ "sha256": "8dbda9a72b42ad133f2e8cfe5a5e3c167b4a4073bab9ebde142b471429ff7a05"
+ },
+ "decoder": {
+ "path": "decoder.onnx",
+ "sha256": "55ca146d887f2acd1911e59b21d1a205e5971064e2ad1fd4986302de03c17a14"
+ }
+ },
+ "benchmark": {
+ "createdAt": "2026-04-27T04:10:42+00:00",
+ "validationSamples": 1497,
+ "evaluations": {
+ "clean": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "clean",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 1.0,
+ "exactMatch": 1.0,
+ "decodeSuccess": 1.0,
+ "createdAt": "2026-04-27T04:07:27+00:00"
+ },
+ "medium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9989802042643229,
+ "exactMatch": 0.9641927083333334,
+ "decodeSuccess": 0.9921875,
+ "createdAt": "2026-04-27T04:07:53+00:00"
+ },
+ "hard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9915457831488715,
+ "exactMatch": 0.824001736111111,
+ "decodeSuccess": 0.912326388888889,
+ "createdAt": "2026-04-27T04:08:22+00:00"
+ },
+ "fullMedium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9992650349934896,
+ "exactMatch": 0.9615885416666666,
+ "decodeSuccess": 0.9895833333333334,
+ "createdAt": "2026-04-27T04:10:09+00:00"
+ },
+ "fullHard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9978663126627604,
+ "exactMatch": 0.9231770833333334,
+ "decodeSuccess": 0.9772135416666666,
+ "createdAt": "2026-04-27T04:10:42+00:00"
+ }
+ }
+ },
+ "artifacts": {
+ "promotedDir": "artifacts/promoted/mlwm-v1-alpha1",
+ "runDir": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38"
+ }
+}
\ No newline at end of file
diff --git a/artifacts/promoted/mlwm-v1-alpha1/train_config_resolved.yaml b/artifacts/promoted/mlwm-v1-alpha1/train_config_resolved.yaml
new file mode 100644
index 0000000..669c784
--- /dev/null
+++ b/artifacts/promoted/mlwm-v1-alpha1/train_config_resolved.yaml
@@ -0,0 +1,106 @@
+seed: 20260426
+image_size: 448
+batch_size: 8
+grad_accum: 2
+num_workers: 12
+device: cuda
+amp: true
+max_text_bytes: 16
+payload_bits: 256
+train_dir: data/train_images
+val_dir: data/val_images
+artifacts_dir: artifacts/mlwm_v1
+promoted_dir: artifacts/promoted
+models_dir: resources/models/neural_wm
+optimizer:
+ lr: 4.0e-05
+ weight_decay: 1.0e-05
+loss:
+ payload_weight: 10.0
+ image_weight: 0.6
+ tv_weight: 0.01
+ residual_weight: 0.02
+stages:
+ warmup:
+ enabled: false
+ epochs: 3
+ freeze_encoder: true
+ fixed_residual_scale: 0.04
+ attack_strength: medium
+ main:
+ enabled: false
+ epochs: 12
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: true
+ epochs: 18
+ freeze_encoder: true
+ attack_strength: medium
+ freeze_decoder: false
+ encoder_teacher_weight: 150.0
+ encoder_teacher_scale: 0.034
+ loss:
+ payload_weight: 14.0
+ image_weight: 0.0
+ tv_weight: 0.0
+ residual_weight: 0.0
+ use_fixed_residual: false
+ finalize:
+ enabled: true
+ epochs: 8
+ freeze_encoder: false
+ attack_strength: medium
+ freeze_decoder: false
+ loss:
+ payload_weight: 12.0
+ image_weight: 0.65
+ tv_weight: 0.018
+ residual_weight: 0.025
+attack:
+ ops_per_sample:
+ min: 1
+ max: 1
+ jpeg_quality:
+ - 75
+ - 95
+ webp_quality:
+ - 75
+ - 95
+ resize_scale:
+ - 0.85
+ - 1.15
+ crop_keep:
+ - 0.96
+ - 1.0
+ rotation_deg:
+ - -1.0
+ - 1.0
+ perspective_ratio:
+ - 0.0
+ - 0.01
+ gaussian_blur_sigma:
+ - 0.0
+ - 0.35
+ motion_blur_ksize:
+ - 3
+ - 5
+ gaussian_noise_std:
+ - 0.0
+ - 0.004
+ overlay_area:
+ - 0.01
+ - 0.04
+ overlay_alpha:
+ - 0.1
+ - 0.25
+ enabled_ops:
+ - crop
+ - screenshot_sim
+ - color_jitter
+model:
+ residual_scale: 0.058823529411764705
+dashboard:
+ status_interval_batches: 1
+ sample_interval_batches: 10
+checkpoint: artifacts/mlwm_v1/runs/20260426T174908+0000_e7ba793/best.ckpt
diff --git a/blind_watermark/__init__.py b/blind_watermark/__init__.py
new file mode 100644
index 0000000..4cb1856
--- /dev/null
+++ b/blind_watermark/__init__.py
@@ -0,0 +1 @@
+"""LuminCrypt blind watermark engines."""
diff --git a/blind_watermark/bwm_helper.py b/blind_watermark/bwm_helper.py
index 9851a0e..775f777 100644
--- a/blind_watermark/bwm_helper.py
+++ b/blind_watermark/bwm_helper.py
@@ -1,200 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-bwm_helper.py — CLI bridge between Electron main process and rwm_engine.
-Outputs a single JSON line to stdout.
+bwm_helper.py - CLI bridge between Electron and the image watermark engines.
+
+The helper prints exactly one JSON object to stdout.
"""
-import sys
-import os
-import json
+from __future__ import annotations
+
import argparse
+import json
+import os
+import sys
import traceback
+
import numpy as np
-# Force UTF-8 stdout/stderr on all platforms (critical for Windows PyInstaller exe)
+
if sys.stdout.encoding and sys.stdout.encoding.upper() not in ('UTF-8', 'UTF8'):
- sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf-8', errors='replace', buffering=1)
+ sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf-8', errors='replace', buffering=1)
if sys.stderr.encoding and sys.stderr.encoding.upper() not in ('UTF-8', 'UTF8'):
- sys.stderr = open(sys.stderr.fileno(), mode='w', encoding='utf-8', errors='replace', buffering=1)
+ sys.stderr = open(sys.stderr.fileno(), mode='w', encoding='utf-8', errors='replace', buffering=1)
-_script_dir = os.path.dirname(os.path.abspath(__file__))
-if _script_dir not in sys.path:
- sys.path.insert(0, _script_dir)
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+if _SCRIPT_DIR not in sys.path:
+ sys.path.insert(0, _SCRIPT_DIR)
def imread_unicode(path, flags=None):
- """cv2.imread replacement that handles Unicode paths on Windows."""
- import cv2
- if flags is None:
- flags = cv2.IMREAD_UNCHANGED
- buf = np.fromfile(path, dtype=np.uint8)
- return cv2.imdecode(buf, flags)
+ import cv2
+ if flags is None:
+ flags = cv2.IMREAD_UNCHANGED
+ buffer = np.fromfile(path, dtype=np.uint8)
+ return cv2.imdecode(buffer, flags)
def imwrite_unicode(path, img, params=None):
- """cv2.imwrite replacement that handles Unicode paths on Windows."""
- import cv2
- ext = os.path.splitext(path)[1]
- ok, buf = cv2.imencode(ext, img, params or [])
- if ok:
- buf.tofile(path)
- return ok
-
-
-def cmd_check(_args):
- """Verify that rwm_engine and all dependencies are available."""
- try:
- from rwm_engine import check_dependencies
- result = check_dependencies()
- if result['ok']:
- print(json.dumps({'ok': True, 'version': result['version']}), flush=True)
- else:
- print(json.dumps({
- 'ok': False,
- 'error': f"Missing dependencies: {', '.join(result['missing'])}. "
- f"Run: pip install {' '.join(result['missing'])}"
- }), flush=True)
- except ImportError as exc:
- print(json.dumps({'ok': False, 'error': str(exc)}), flush=True)
+ import cv2
+ ext = os.path.splitext(path)[1]
+ ok, buffer = cv2.imencode(ext, img, params or [])
+ if ok:
+ buffer.tofile(path)
+ return ok
+
+
+def _apply_models_dir(args) -> None:
+ models_dir = getattr(args, 'models_dir', '')
+ if models_dir:
+ os.environ['LUMINCRYPT_MLWM_MODELS_DIR'] = models_dir
+
+
+def cmd_check(args):
+ try:
+ _apply_models_dir(args)
+ from rwm_engine import check_dependencies
+
+ result = check_dependencies()
+ if not result.get('ok'):
+ result['error'] = (
+ f"Missing dependencies: {', '.join(result.get('missing', []))}. "
+ f"Run: pip install {' '.join(result.get('missing', []))}"
+ )
+ print(json.dumps(result), flush=True)
+ except ImportError as exc:
+ print(json.dumps({'ok': False, 'error': str(exc)}), flush=True)
def cmd_embed(args):
- """Embed a text watermark into an image."""
- if not args.output:
- print(json.dumps({'ok': False, 'error': '--output is required for embed mode'}), flush=True)
- return
- if not args.wm:
- print(json.dumps({'ok': False, 'error': '--wm is required for embed mode'}), flush=True)
- return
- if not os.path.isfile(args.input):
- print(json.dumps({'ok': False, 'error': f'Input file not found: {args.input}'}), flush=True)
- return
-
- try:
- from rwm_engine import embed_watermark
- except ImportError as exc:
- print(json.dumps({
- 'ok': False,
- 'error': f'Cannot import dependencies: {exc}. '
- f'Run: pip install numpy opencv-python scipy reedsolo'
- }), flush=True)
- return
-
- try:
- img = imread_unicode(args.input)
- if img is None:
- print(json.dumps({'ok': False, 'error': f'Cannot read image: {args.input}'}), flush=True)
- return
-
- self_check = getattr(args, 'self_check', True)
- result = embed_watermark(
- img=img,
- text=args.wm,
- password=args.password,
- quality=args.quality,
- self_check=self_check,
- )
-
- imwrite_unicode(args.output, result)
-
- print(json.dumps({
- 'ok': True,
- 'output': args.output,
- 'quality': args.quality,
- }), flush=True)
-
- except Exception as exc:
- print(json.dumps({
- 'ok': False,
- 'error': str(exc),
- 'detail': traceback.format_exc()
- }), flush=True)
+ if not args.output:
+ print(json.dumps({'ok': False, 'error': '--output is required for embed mode'}), flush=True)
+ return
+ if not args.wm:
+ print(json.dumps({'ok': False, 'error': '--wm is required for embed mode'}), flush=True)
+ return
+ if not os.path.isfile(args.input):
+ print(json.dumps({'ok': False, 'error': f'Input file not found: {args.input}'}), flush=True)
+ return
+
+ try:
+ _apply_models_dir(args)
+ from rwm_engine import embed_watermark
+ except ImportError as exc:
+ print(json.dumps({
+ 'ok': False,
+ 'error': f'Cannot import dependencies: {exc}. Run: pip install -r blind_watermark/requirements.txt',
+ }), flush=True)
+ return
+
+ try:
+ image = imread_unicode(args.input)
+ if image is None:
+ print(json.dumps({'ok': False, 'error': f'Cannot read image: {args.input}'}), flush=True)
+ return
+
+ result = embed_watermark(
+ img=image,
+ text=args.wm,
+ password=args.password,
+ quality=args.quality,
+ engine=args.engine,
+ models_dir=args.models_dir or None,
+ self_check=getattr(args, 'self_check', True),
+ )
+ if not result.get('ok'):
+ print(json.dumps(result), flush=True)
+ return
+
+ imwrite_unicode(args.output, result['image'])
+ payload = {
+ 'ok': True,
+ 'output': args.output,
+ 'quality': result.get('quality_used', args.quality),
+ 'engineUsed': result.get('engine_used'),
+ 'fallbackUsed': result.get('fallback_used', False),
+ 'confidence': result.get('confidence'),
+ 'diagnostics': result.get('diagnostics', {}),
+ }
+ print(json.dumps(payload), flush=True)
+ except Exception as exc:
+ print(json.dumps({
+ 'ok': False,
+ 'error': str(exc),
+ 'detail': traceback.format_exc(),
+ }), flush=True)
def cmd_extract(args):
- """Extract a text watermark from an image."""
- if not os.path.isfile(args.input):
- print(json.dumps({'ok': False, 'error': f'Input file not found: {args.input}'}), flush=True)
- return
-
- try:
- from rwm_engine import extract_watermark
- except ImportError as exc:
- print(json.dumps({
- 'ok': False,
- 'error': f'Cannot import dependencies: {exc}. '
- f'Run: pip install numpy opencv-python scipy reedsolo'
- }), flush=True)
- return
-
- try:
- img = imread_unicode(args.input)
- if img is None:
- print(json.dumps({'ok': False, 'error': f'Cannot read image: {args.input}'}), flush=True)
- return
-
- wm_text = extract_watermark(
- img=img,
- password=args.password,
- quality=args.quality,
- )
-
- print(json.dumps({'ok': True, 'wm': wm_text}), flush=True)
-
- except ValueError as exc:
- print(json.dumps({'ok': False, 'error': str(exc)}), flush=True)
- except Exception as exc:
- print(json.dumps({
- 'ok': False,
- 'error': str(exc),
- 'detail': traceback.format_exc()
- }), flush=True)
-
-
-def main():
- # If first arg is '--json-stdin', read all options from a JSON object on stdin.
- # This avoids Windows command-line encoding issues with Unicode paths.
- if len(sys.argv) == 2 and sys.argv[1] == '--json-stdin':
- import io
- raw = sys.stdin.buffer.read()
- opts = json.loads(raw.decode('utf-8'))
-
- class _Args:
- pass
-
- args = _Args()
- args.mode = opts['mode']
- args.input = opts.get('input', '')
- args.output = opts.get('output', '')
- args.wm = opts.get('wm', '')
- args.password = int(opts.get('password', 1))
- args.quality = opts.get('quality', 'balanced')
- args.self_check = opts.get('self_check', True)
+ if not os.path.isfile(args.input):
+ print(json.dumps({'ok': False, 'error': f'Input file not found: {args.input}'}), flush=True)
+ return
+
+ try:
+ _apply_models_dir(args)
+ from rwm_engine import extract_watermark
+ except ImportError as exc:
+ print(json.dumps({
+ 'ok': False,
+ 'error': f'Cannot import dependencies: {exc}. Run: pip install -r blind_watermark/requirements.txt',
+ }), flush=True)
+ return
+
+ try:
+ image = imread_unicode(args.input)
+ if image is None:
+ print(json.dumps({'ok': False, 'error': f'Cannot read image: {args.input}'}), flush=True)
+ return
+
+ result = extract_watermark(
+ img=image,
+ password=args.password,
+ quality=args.quality,
+ engine=args.engine,
+ models_dir=args.models_dir or None,
+ )
+ if result.get('ok'):
+ payload = {
+ 'ok': True,
+ 'wm': result.get('wm'),
+ 'engineUsed': result.get('engine_used'),
+ 'fallbackUsed': result.get('fallback_used', False),
+ 'confidence': result.get('confidence'),
+ 'diagnostics': result.get('diagnostics', {}),
+ }
+ print(json.dumps(payload), flush=True)
else:
- parser = argparse.ArgumentParser(description='Robust Watermark Engine — Electron bridge')
- parser.add_argument('--mode', choices=['check', 'embed', 'extract'], required=True,
- help='Operation mode')
- parser.add_argument('--input', default='',
- help='Path to the source image')
- parser.add_argument('--output', default='',
- help='Path for the output image (embed only)')
- parser.add_argument('--wm', default='',
- help='Watermark text to embed')
- parser.add_argument('--password', type=int, default=1,
- help='Integer password for watermark encryption (default: 1)')
- parser.add_argument('--quality', choices=['invisible', 'balanced', 'robust'],
- default='balanced',
- help='Quality preset: invisible, balanced, robust (default: balanced)')
- args = parser.parse_args()
-
- if args.mode == 'check':
- cmd_check(args)
- elif args.mode == 'embed':
- cmd_embed(args)
- elif args.mode == 'extract':
- cmd_extract(args)
+ payload = dict(result)
+ if 'engine_used' in payload:
+ payload['engineUsed'] = payload.pop('engine_used')
+ if 'fallback_used' in payload:
+ payload['fallbackUsed'] = payload.pop('fallback_used')
+ print(json.dumps(payload), flush=True)
+ except Exception as exc:
+ print(json.dumps({
+ 'ok': False,
+ 'error': str(exc),
+ 'detail': traceback.format_exc(),
+ }), flush=True)
+
+
+def parse_args():
+ if len(sys.argv) == 2 and sys.argv[1] == '--json-stdin':
+ raw = sys.stdin.buffer.read()
+ opts = json.loads(raw.decode('utf-8'))
+
+ class _Args:
+ pass
+
+ args = _Args()
+ args.mode = opts['mode']
+ args.input = opts.get('input', '')
+ args.output = opts.get('output', '')
+ args.wm = opts.get('wm', '')
+ args.password = int(opts.get('password', 1))
+ args.quality = opts.get('quality', 'balanced')
+ args.self_check = opts.get('self_check', True)
+ args.engine = opts.get('engine', 'auto')
+ args.models_dir = opts.get('models_dir', '')
+ return args
+
+ parser = argparse.ArgumentParser(description='Robust Watermark Engine bridge')
+ parser.add_argument('--mode', choices=['check', 'embed', 'extract'], required=True)
+ parser.add_argument('--input', default='')
+ parser.add_argument('--output', default='')
+ parser.add_argument('--wm', default='')
+ parser.add_argument('--password', type=int, default=1)
+ parser.add_argument('--quality', choices=['invisible', 'balanced', 'robust'], default='balanced')
+ parser.add_argument('--engine', choices=['auto', 'legacy', 'neural'], default='auto')
+ parser.add_argument('--models-dir', default='')
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ if args.mode == 'check':
+ cmd_check(args)
+ elif args.mode == 'embed':
+ cmd_embed(args)
+ elif args.mode == 'extract':
+ cmd_extract(args)
if __name__ == '__main__':
- main()
+ main()
diff --git a/blind_watermark/mlwm/__init__.py b/blind_watermark/mlwm/__init__.py
new file mode 100644
index 0000000..6b395dc
--- /dev/null
+++ b/blind_watermark/mlwm/__init__.py
@@ -0,0 +1,17 @@
+"""MLWM v1 - neural robust image watermark helpers."""
+
+from .codec import (
+ ENCODED_BYTES,
+ PAYLOAD_BITS,
+ MAX_TEXT_BYTES,
+ decode_payload_bits,
+ encode_text_payload,
+)
+
+__all__ = [
+ 'ENCODED_BYTES',
+ 'PAYLOAD_BITS',
+ 'MAX_TEXT_BYTES',
+ 'decode_payload_bits',
+ 'encode_text_payload',
+]
diff --git a/blind_watermark/mlwm/attacks.py b/blind_watermark/mlwm/attacks.py
new file mode 100644
index 0000000..2a9f9e6
--- /dev/null
+++ b/blind_watermark/mlwm/attacks.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import random
+from dataclasses import dataclass
+from typing import Any
+
+import cv2
+import numpy as np
+
+
+@dataclass
+class AttackConfig:
+ enabled_ops: tuple[str, ...] | None = None
+ ops_per_sample_min: int = 2
+ ops_per_sample_max: int = 5
+ jpeg_quality: tuple[int, int] = (30, 95)
+ webp_quality: tuple[int, int] = (25, 90)
+ resize_scale: tuple[float, float] = (0.45, 1.4)
+ crop_keep: tuple[float, float] = (0.8, 1.0)
+ rotation_deg: tuple[float, float] = (-7.0, 7.0)
+ perspective_ratio: tuple[float, float] = (0.0, 0.04)
+ gaussian_blur_sigma: tuple[float, float] = (0.0, 2.0)
+ motion_blur_ksize: tuple[int, int] = (3, 9)
+ gaussian_noise_std: tuple[float, float] = (0.0, 8.0 / 255.0)
+ overlay_area: tuple[float, float] = (0.03, 0.15)
+ overlay_alpha: tuple[float, float] = (0.15, 0.5)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> 'AttackConfig':
+ enabled_ops = data.get('enabled_ops')
+ return cls(
+ enabled_ops=tuple(enabled_ops) if enabled_ops else None,
+ ops_per_sample_min=int(data.get('ops_per_sample', {}).get('min', 2)),
+ ops_per_sample_max=int(data.get('ops_per_sample', {}).get('max', 5)),
+ jpeg_quality=tuple(data.get('jpeg_quality', (30, 95))),
+ webp_quality=tuple(data.get('webp_quality', (25, 90))),
+ resize_scale=tuple(data.get('resize_scale', (0.45, 1.4))),
+ crop_keep=tuple(data.get('crop_keep', (0.8, 1.0))),
+ rotation_deg=tuple(data.get('rotation_deg', (-7.0, 7.0))),
+ perspective_ratio=tuple(data.get('perspective_ratio', (0.0, 0.04))),
+ gaussian_blur_sigma=tuple(data.get('gaussian_blur_sigma', (0.0, 2.0))),
+ motion_blur_ksize=tuple(data.get('motion_blur_ksize', (3, 9))),
+ gaussian_noise_std=tuple(data.get('gaussian_noise_std', (0.0, 8.0 / 255.0))),
+ overlay_area=tuple(data.get('overlay_area', (0.03, 0.15))),
+ overlay_alpha=tuple(data.get('overlay_alpha', (0.15, 0.5))),
+ )
+
+
+def _encode_decode(image: np.ndarray, ext: str, params: list[int]) -> np.ndarray:
+ ok, buf = cv2.imencode(ext, image[:, :, ::-1], params)
+ if not ok:
+ return image
+ dec = cv2.imdecode(buf, cv2.IMREAD_COLOR)
+ if dec is None:
+ return image
+ return dec[:, :, ::-1]
+
+
+def attack_jpeg(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ quality = rng.randint(*cfg.jpeg_quality)
+ return _encode_decode(image, '.jpg', [int(cv2.IMWRITE_JPEG_QUALITY), quality])
+
+
+def attack_webp(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ quality = rng.randint(*cfg.webp_quality)
+ return _encode_decode(image, '.webp', [int(cv2.IMWRITE_WEBP_QUALITY), quality])
+
+
+def attack_resize(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ h, w = image.shape[:2]
+ scale = rng.uniform(*cfg.resize_scale)
+ nh = max(32, int(h * scale))
+ nw = max(32, int(w * scale))
+ interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
+ resized = cv2.resize(image, (nw, nh), interpolation=interp)
+ return cv2.resize(resized, (w, h), interpolation=cv2.INTER_CUBIC)
+
+
+def attack_crop(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ h, w = image.shape[:2]
+ keep = rng.uniform(*cfg.crop_keep)
+ ch = max(32, int(h * keep))
+ cw = max(32, int(w * keep))
+ y0 = rng.randint(0, max(0, h - ch))
+ x0 = rng.randint(0, max(0, w - cw))
+ crop = image[y0:y0 + ch, x0:x0 + cw]
+ return cv2.resize(crop, (w, h), interpolation=cv2.INTER_CUBIC)
+
+
+def attack_rotate(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ h, w = image.shape[:2]
+ angle = rng.uniform(*cfg.rotation_deg)
+ matrix = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0)
+ return cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
+
+
+def attack_perspective(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ h, w = image.shape[:2]
+ ratio = rng.uniform(*cfg.perspective_ratio)
+ dx = int(w * ratio)
+ dy = int(h * ratio)
+ src = np.float32([[0, 0], [w - 1, 0], [0, h - 1], [w - 1, h - 1]])
+ dst = np.float32([
+ [rng.randint(0, dx), rng.randint(0, dy)],
+ [w - 1 - rng.randint(0, dx), rng.randint(0, dy)],
+ [rng.randint(0, dx), h - 1 - rng.randint(0, dy)],
+ [w - 1 - rng.randint(0, dx), h - 1 - rng.randint(0, dy)],
+ ])
+ matrix = cv2.getPerspectiveTransform(src, dst)
+ return cv2.warpPerspective(image, matrix, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
+
+
+def attack_gaussian_blur(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ sigma = rng.uniform(*cfg.gaussian_blur_sigma)
+ if sigma <= 1e-6:
+ return image
+ return cv2.GaussianBlur(image, (0, 0), sigmaX=sigma, sigmaY=sigma)
+
+
+def attack_motion_blur(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ k = rng.randint(*cfg.motion_blur_ksize)
+ if k % 2 == 0:
+ k += 1
+ kernel = np.zeros((k, k), dtype=np.float32)
+ kernel[k // 2, :] = 1.0 / k
+ angle = rng.uniform(0.0, 180.0)
+ center = (k / 2.0, k / 2.0)
+ matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
+ kernel = cv2.warpAffine(kernel, matrix, (k, k))
+ kernel_sum = np.sum(kernel)
+ kernel = kernel / kernel_sum if kernel_sum > 0 else kernel
+ return cv2.filter2D(image, -1, kernel)
+
+
+def attack_gaussian_noise(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ std = rng.uniform(*cfg.gaussian_noise_std)
+ if std <= 1e-8:
+ return image
+ noise = np.random.default_rng(rng.randint(0, 2**31 - 1)).normal(0.0, std * 255.0, size=image.shape)
+ return np.clip(image.astype(np.float32) + noise, 0, 255).astype(np.uint8)
+
+
+def attack_color_jitter(image: np.ndarray, rng: random.Random) -> np.ndarray:
+ out = image.astype(np.float32)
+ alpha = rng.uniform(0.85, 1.15)
+ beta = rng.uniform(-12.0, 12.0)
+ out = np.clip(out * alpha + beta, 0, 255)
+ hsv = cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32)
+ hsv[..., 1] = np.clip(hsv[..., 1] * rng.uniform(0.85, 1.2), 0, 255)
+ hsv[..., 2] = np.clip(hsv[..., 2] * rng.uniform(0.9, 1.1), 0, 255)
+ out = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32)
+ gamma = rng.uniform(0.85, 1.15)
+ out = np.clip(((out / 255.0) ** gamma) * 255.0, 0, 255)
+ return out.astype(np.uint8)
+
+
+def attack_sharpen(image: np.ndarray, rng: random.Random) -> np.ndarray:
+ blur = cv2.GaussianBlur(image, (0, 0), sigmaX=1.2)
+ alpha = rng.uniform(0.15, 0.6)
+ return np.clip(image.astype(np.float32) * (1.0 + alpha) - blur.astype(np.float32) * alpha, 0, 255).astype(np.uint8)
+
+
+def attack_corner_overlay(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ h, w = image.shape[:2]
+ area = rng.uniform(*cfg.overlay_area)
+ alpha = rng.uniform(*cfg.overlay_alpha)
+ oh = max(16, int(h * area))
+ ow = max(16, int(w * area))
+ corner = rng.choice(['tl', 'tr', 'bl', 'br'])
+ if corner == 'tl':
+ y0, x0 = 0, 0
+ elif corner == 'tr':
+ y0, x0 = 0, w - ow
+ elif corner == 'bl':
+ y0, x0 = h - oh, 0
+ else:
+ y0, x0 = h - oh, w - ow
+ color = np.array([rng.randint(180, 255), rng.randint(180, 255), rng.randint(180, 255)], dtype=np.float32)
+ out = image.astype(np.float32)
+ out[y0:y0 + oh, x0:x0 + ow] = out[y0:y0 + oh, x0:x0 + ow] * (1.0 - alpha) + color * alpha
+ return np.clip(out, 0, 255).astype(np.uint8)
+
+
+def attack_screenshot_sim(image: np.ndarray, cfg: AttackConfig, rng: random.Random) -> np.ndarray:
+ out = attack_resize(image, cfg, rng)
+ out = attack_sharpen(out, rng)
+ out = attack_color_jitter(out, rng)
+ out = attack_jpeg(out, cfg, rng)
+ return out
+
+
+ATTACK_REGISTRY = {
+ 'jpeg': attack_jpeg,
+ 'webp': attack_webp,
+ 'resize': attack_resize,
+ 'crop': attack_crop,
+ 'rotate': attack_rotate,
+ 'perspective': attack_perspective,
+ 'gaussian_blur': attack_gaussian_blur,
+ 'motion_blur': attack_motion_blur,
+ 'gaussian_noise': attack_gaussian_noise,
+ 'color_jitter': lambda image, cfg, rng: attack_color_jitter(image, rng),
+ 'sharpen': lambda image, cfg, rng: attack_sharpen(image, rng),
+ 'corner_overlay': attack_corner_overlay,
+ 'screenshot_sim': attack_screenshot_sim,
+}
+
+
+def apply_random_attack_chain(
+ image: np.ndarray,
+ *,
+ config: AttackConfig,
+ rng: random.Random | None = None,
+ strength: str = 'medium',
+) -> np.ndarray:
+ if strength in {'clean', 'none', 'off'}:
+ return np.asarray(image, dtype=np.uint8).copy()
+ local_rng = rng or random.Random()
+ out = np.asarray(image, dtype=np.uint8).copy()
+ names = list(config.enabled_ops or ATTACK_REGISTRY.keys())
+ max_ops = config.ops_per_sample_max + (1 if strength == 'hard' else 0)
+ min_ops = config.ops_per_sample_min if strength != 'mixed' else 1
+ num_ops = local_rng.randint(min_ops, max_ops)
+ chosen = local_rng.sample(names, k=min(num_ops, len(names)))
+ for name in chosen:
+ out = ATTACK_REGISTRY[name](out, config, local_rng)
+ return out
diff --git a/blind_watermark/mlwm/bench.py b/blind_watermark/mlwm/bench.py
new file mode 100644
index 0000000..8d2a304
--- /dev/null
+++ b/blind_watermark/mlwm/bench.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import cv2
+
+from .attacks import AttackConfig, attack_corner_overlay, attack_crop, attack_gaussian_blur, attack_gaussian_noise, attack_jpeg, attack_resize, attack_rotate, attack_screenshot_sim, attack_webp
+from .dataset import discover_images, load_rgb_image
+
+
+def build_attack_suite(cfg: AttackConfig):
+ return {
+ 'clean': lambda image: image,
+ 'jpeg_q75': lambda image: attack_jpeg(image, AttackConfig(jpeg_quality=(75, 75)), __import__('random').Random(1)),
+ 'jpeg_q50': lambda image: attack_jpeg(image, AttackConfig(jpeg_quality=(50, 50)), __import__('random').Random(2)),
+ 'webp_q50': lambda image: attack_webp(image, AttackConfig(webp_quality=(50, 50)), __import__('random').Random(3)),
+ 'resize_half': lambda image: attack_resize(image, AttackConfig(resize_scale=(0.5, 0.5)), __import__('random').Random(4)),
+ 'crop_10pct': lambda image: attack_crop(image, AttackConfig(crop_keep=(0.9, 0.9)), __import__('random').Random(5)),
+ 'rotation3_jpeg75': lambda image: attack_jpeg(attack_rotate(image, AttackConfig(rotation_deg=(3.0, 3.0)), __import__('random').Random(6)), AttackConfig(jpeg_quality=(75, 75)), __import__('random').Random(7)),
+ 'blur_noise': lambda image: attack_gaussian_noise(attack_gaussian_blur(image, cfg, __import__('random').Random(8)), cfg, __import__('random').Random(9)),
+ 'corner_overlay': lambda image: attack_corner_overlay(image, cfg, __import__('random').Random(10)),
+ 'screenshot_sim': lambda image: attack_screenshot_sim(image, cfg, __import__('random').Random(11)),
+ }
+
+
+def bench_engine(engine: str, paths: list[str], text: str, quality: str, password: int) -> dict:
+ from blind_watermark.rwm_engine import embed_watermark, extract_watermark
+
+ cfg = AttackConfig()
+ suite = build_attack_suite(cfg)
+ cases = {name: {'success': 0, 'total': 0} for name in suite}
+
+ for path in paths:
+ rgb = load_rgb_image(path)
+ bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
+ embedded = embed_watermark(bgr, text, password=password, quality=quality, engine=engine)
+ if not embedded.get('ok'):
+ return {'engine': engine, 'ok': False, 'error': embedded.get('error')}
+ wm_image = embedded['image']
+ for name, attack_fn in suite.items():
+ attacked_rgb = attack_fn(cv2.cvtColor(wm_image, cv2.COLOR_BGR2RGB))
+ attacked_bgr = cv2.cvtColor(attacked_rgb, cv2.COLOR_RGB2BGR)
+ extracted = extract_watermark(attacked_bgr, password=password, engine=engine, quality=quality)
+ cases[name]['total'] += 1
+ if extracted.get('ok') and extracted.get('wm') == text:
+ cases[name]['success'] += 1
+
+ summary = {
+ name: (data['success'] / data['total'] if data['total'] else 0.0)
+ for name, data in cases.items()
+ }
+ return {'engine': engine, 'ok': True, 'summary': summary}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Benchmark MLWM and legacy engines')
+ parser.add_argument('--input-dir', required=True)
+ parser.add_argument('--output', default='artifacts/mlwm_v1/benchmark.json')
+ parser.add_argument('--text', default='TRACE-MLWM-V1')
+ parser.add_argument('--quality', default='balanced')
+ parser.add_argument('--password', type=int, default=1)
+ parser.add_argument('--limit', type=int, default=8)
+ args = parser.parse_args()
+
+ paths = discover_images(args.input_dir)[:args.limit]
+ if not paths:
+ raise ValueError(f'no benchmark images found under {args.input_dir}')
+
+ payload = {
+ 'legacy': bench_engine('legacy', paths, args.text, args.quality, args.password),
+ 'neural': bench_engine('neural', paths, args.text, args.quality, args.password),
+ 'auto': bench_engine('auto', paths, args.text, args.quality, args.password),
+ }
+ out_path = Path(args.output)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
+ print(json.dumps(payload, ensure_ascii=False))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/codec.py b/blind_watermark/mlwm/codec.py
new file mode 100644
index 0000000..ae59808
--- /dev/null
+++ b/blind_watermark/mlwm/codec.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import zlib
+from dataclasses import dataclass
+from typing import Any
+
+import numpy as np
+import reedsolo
+
+FRAME_VERSION = 1
+ENGINE_NEURAL_ID = 1
+MAX_TEXT_BYTES = 16
+FRAME_BYTES = 24
+RS_NSYM = 8
+ENCODED_BYTES = FRAME_BYTES + RS_NSYM
+PAYLOAD_BITS = ENCODED_BYTES * 8
+WHITENING_SEED = 0x4d4c574d
+
+
+@dataclass
+class PayloadEnvelope:
+ text: str
+ text_bytes: bytes
+ frame: bytes
+ encoded: bytes
+ bits: np.ndarray
+ flags: int = 0
+
+
+def _whitening_mask() -> np.ndarray:
+ rng = np.random.default_rng(WHITENING_SEED)
+ return rng.integers(0, 2, PAYLOAD_BITS, dtype=np.uint8).astype(np.float32)
+
+
+_PAYLOAD_WHITENING_MASK = _whitening_mask()
+
+
+def _rs_codec() -> reedsolo.RSCodec:
+ return reedsolo.RSCodec(RS_NSYM)
+
+
+def bytes_to_bits(data: bytes) -> np.ndarray:
+ out = np.zeros(len(data) * 8, dtype=np.float32)
+ for i, b in enumerate(data):
+ for j in range(8):
+ out[i * 8 + j] = float((b >> (7 - j)) & 1)
+ return out
+
+
+def bits_to_bytes(bits: np.ndarray | list[float] | list[int]) -> bytes:
+ flat = np.asarray(bits, dtype=np.float32).reshape(-1)
+ if flat.size % 8 != 0:
+ raise ValueError('bit array length must be divisible by 8')
+ out = bytearray(flat.size // 8)
+ for i in range(out.__len__()):
+ value = 0
+ for j in range(8):
+ value = (value << 1) | int(flat[i * 8 + j] >= 0.5)
+ out[i] = value
+ return bytes(out)
+
+
+def whiten_payload_bits(bits: np.ndarray | list[float] | list[int]) -> np.ndarray:
+ flat = np.asarray(bits, dtype=np.float32).reshape(-1)
+ if flat.size != PAYLOAD_BITS:
+ raise ValueError(f'expected {PAYLOAD_BITS} payload bits, got {flat.size}')
+ hard = (flat >= 0.5).astype(np.float32)
+ return np.abs(hard - _PAYLOAD_WHITENING_MASK).astype(np.float32)
+
+
+def unwhiten_payload_bits(bits: np.ndarray | list[float] | list[int]) -> np.ndarray:
+ return whiten_payload_bits(bits)
+
+
+def build_frame(payload_bytes: bytes, *, flags: int = 0) -> bytes:
+ if len(payload_bytes) > MAX_TEXT_BYTES:
+ raise ValueError(f'neural payload supports up to {MAX_TEXT_BYTES} UTF-8 bytes')
+ header = bytes([
+ FRAME_VERSION & 0xff,
+ ENGINE_NEURAL_ID & 0xff,
+ len(payload_bytes) & 0xff,
+ flags & 0xff,
+ ])
+ crc = zlib.crc32(payload_bytes) & 0xffffffff
+ crc_bytes = crc.to_bytes(4, 'big')
+ padded = payload_bytes.ljust(MAX_TEXT_BYTES, b'\0')
+ frame = header + crc_bytes + padded
+ if len(frame) != FRAME_BYTES:
+ raise AssertionError(f'unexpected frame length: {len(frame)}')
+ return frame
+
+
+def encode_frame(frame: bytes) -> bytes:
+ if len(frame) != FRAME_BYTES:
+ raise ValueError(f'frame must be exactly {FRAME_BYTES} bytes')
+ return bytes(_rs_codec().encode(frame))
+
+
+def encode_text_payload(text: str, *, flags: int = 0) -> PayloadEnvelope:
+ payload_bytes = text.encode('utf-8')
+ frame = build_frame(payload_bytes, flags=flags)
+ encoded = encode_frame(frame)
+ return PayloadEnvelope(
+ text=text,
+ text_bytes=payload_bytes,
+ frame=frame,
+ encoded=encoded,
+ bits=whiten_payload_bits(bytes_to_bits(encoded)),
+ flags=flags,
+ )
+
+
+def decode_frame(encoded_bytes: bytes) -> dict[str, Any]:
+ if len(encoded_bytes) != ENCODED_BYTES:
+ raise ValueError(f'encoded payload must be {ENCODED_BYTES} bytes')
+ decoded = _rs_codec().decode(encoded_bytes)
+ frame = decoded[0] if isinstance(decoded, tuple) else decoded
+ frame = bytes(frame)
+ version, engine_id, length, flags = frame[0], frame[1], frame[2], frame[3]
+ crc_expected = int.from_bytes(frame[4:8], 'big')
+ payload = frame[8:24][:length]
+ crc_actual = zlib.crc32(payload) & 0xffffffff
+ if crc_actual != crc_expected:
+ raise ValueError('payload CRC mismatch')
+ text = payload.decode('utf-8', errors='strict')
+ return {
+ 'version': version,
+ 'engineId': engine_id,
+ 'length': length,
+ 'flags': flags,
+ 'text': text,
+ 'payloadBytes': payload,
+ 'crc32': crc_actual,
+ 'frame': frame,
+ }
+
+
+def decode_payload_bits(bits: np.ndarray | list[float] | list[int]) -> dict[str, Any]:
+ raw_bits = unwhiten_payload_bits(bits)
+ encoded = bits_to_bytes(raw_bits)
+ result = decode_frame(encoded)
+ result['encoded'] = encoded
+ result['rawBits'] = raw_bits
+ return result
+
+
+def decode_payload_logits(logits: np.ndarray) -> dict[str, Any]:
+ logits = np.asarray(logits, dtype=np.float32).reshape(-1)
+ if logits.size != PAYLOAD_BITS:
+ raise ValueError(f'expected {PAYLOAD_BITS} logits, got {logits.size}')
+ probs = 1.0 / (1.0 + np.exp(-logits))
+ bits = (probs >= 0.5).astype(np.float32)
+ decoded = decode_payload_bits(bits)
+ decoded['probabilities'] = probs
+ decoded['bitConfidence'] = float(np.mean(np.maximum(probs, 1.0 - probs)))
+ return decoded
diff --git a/blind_watermark/mlwm/dashboard.py b/blind_watermark/mlwm/dashboard.py
new file mode 100644
index 0000000..f860fce
--- /dev/null
+++ b/blind_watermark/mlwm/dashboard.py
@@ -0,0 +1,425 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import mimetypes
+import os
+import subprocess
+import time
+import webbrowser
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+
+DEFAULT_RUNS_DIR = Path('artifacts/mlwm_v1/runs')
+
+
+def _read_text(path: Path, limit: int = 12000) -> str:
+ try:
+ data = path.read_bytes()
+ except OSError:
+ return ''
+ if len(data) > limit:
+ data = data[-limit:]
+ return data.decode('utf-8', errors='replace')
+
+
+def _read_json(path: Path) -> dict[str, Any] | None:
+ try:
+ return json.loads(path.read_text(encoding='utf-8'))
+ except Exception:
+ return None
+
+
+def _read_metrics(path: Path) -> list[dict[str, Any]]:
+ if not path.exists():
+ return []
+ rows: list[dict[str, Any]] = []
+ try:
+ with path.open('r', encoding='utf-8', newline='') as f:
+ for row in csv.DictReader(f):
+ parsed: dict[str, Any] = {}
+ for key, value in row.items():
+ if key == 'stage':
+ parsed[key] = value
+ continue
+ try:
+ parsed[key] = float(value) if key != 'epoch' else int(float(value))
+ except (TypeError, ValueError):
+ parsed[key] = value
+ rows.append(parsed)
+ except OSError:
+ return []
+ return rows
+
+
+def _read_live_status(path: Path) -> dict[str, Any] | None:
+ payload = _read_json(path)
+ return payload if isinstance(payload, dict) else None
+
+
+def _latest_run(runs_dir: Path) -> Path | None:
+ if not runs_dir.exists():
+ return None
+ candidates = [p for p in runs_dir.iterdir() if p.is_dir()]
+ if not candidates:
+ return None
+ return max(candidates, key=lambda p: p.stat().st_mtime)
+
+
+def _gpu_status() -> dict[str, Any]:
+ query = [
+ 'nvidia-smi',
+ '--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total,power.draw',
+ '--format=csv,noheader,nounits',
+ ]
+ try:
+ row = subprocess.check_output(query, stderr=subprocess.DEVNULL, timeout=5).decode('utf-8').strip().splitlines()[0]
+ name, temp, util, mem_used, mem_total, power = [part.strip() for part in row.split(',')]
+ return {
+ 'available': True,
+ 'name': name,
+ 'temperatureC': _to_float(temp),
+ 'utilizationPct': _to_float(util),
+ 'memoryUsedMB': _to_float(mem_used),
+ 'memoryTotalMB': _to_float(mem_total),
+ 'powerW': _to_float(power),
+ }
+ except Exception as exc:
+ return {'available': False, 'error': str(exc)}
+
+
+def _to_float(value: str) -> float | None:
+ try:
+ return float(value)
+ except ValueError:
+ return None
+
+
+def _training_processes() -> list[dict[str, Any]]:
+ if os.name == 'nt':
+ cmd = [
+ 'powershell',
+ '-NoProfile',
+ '-Command',
+ (
+ "Get-CimInstance Win32_Process | "
+ "Where-Object { $_.Name -like 'python*' -and $_.CommandLine -like '*blind_watermark.mlwm.train*' } | "
+ "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress"
+ ),
+ ]
+ try:
+ raw = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=8).decode('utf-8').strip()
+ if not raw:
+ return []
+ data = json.loads(raw)
+ if isinstance(data, dict):
+ data = [data]
+ return [
+ {
+ 'pid': item.get('ProcessId'),
+ 'parentPid': item.get('ParentProcessId'),
+ 'command': item.get('CommandLine'),
+ }
+ for item in data
+ ]
+ except Exception:
+ return []
+ try:
+ raw = subprocess.check_output(['ps', '-eo', 'pid,ppid,args'], stderr=subprocess.DEVNULL, timeout=5).decode('utf-8')
+ except Exception:
+ return []
+ rows = []
+ for line in raw.splitlines()[1:]:
+ if 'blind_watermark.mlwm.train' not in line:
+ continue
+ parts = line.strip().split(maxsplit=2)
+ if len(parts) == 3:
+ rows.append({'pid': int(parts[0]), 'parentPid': int(parts[1]), 'command': parts[2]})
+ return rows
+
+
+def _estimate_progress(metrics: list[dict[str, Any]], config: dict[str, Any] | None, run_dir: Path) -> dict[str, Any]:
+ total_epochs = None
+ if config:
+ total_epochs = 0
+ for stage in ['warmup', 'main', 'hard_negative', 'finalize']:
+ stage_cfg = (config.get('stages') or {}).get(stage) or {}
+ if stage_cfg.get('enabled', False):
+ total_epochs += int(stage_cfg.get('epochs', 0))
+ done = int(metrics[-1]['epoch']) if metrics else 0
+ first_metric_time = None
+ metrics_path = run_dir / 'metrics_epoch.csv'
+ if metrics_path.exists() and done > 0:
+ first_metric_time = metrics_path.stat().st_mtime
+ elapsed_seconds = None
+ eta_seconds = None
+ if metrics and first_metric_time:
+ created = (run_dir / 'train_config_resolved.yaml').stat().st_mtime if (run_dir / 'train_config_resolved.yaml').exists() else run_dir.stat().st_mtime
+ elapsed_seconds = max(1.0, time.time() - created)
+ seconds_per_epoch = elapsed_seconds / max(done, 1)
+ if total_epochs:
+ eta_seconds = max(0.0, (total_epochs - done) * seconds_per_epoch)
+ pct = (done / total_epochs * 100.0) if total_epochs else None
+ return {
+ 'doneEpochs': done,
+ 'totalEpochs': total_epochs,
+ 'percent': pct,
+ 'elapsedSeconds': elapsed_seconds,
+ 'etaSeconds': eta_seconds,
+ }
+
+
+def build_status(runs_dir: Path, run_arg: str | None) -> dict[str, Any]:
+ run_dir = Path(run_arg) if run_arg else (_latest_run(runs_dir) or runs_dir)
+ if not run_dir.is_absolute():
+ run_dir = run_dir.resolve()
+ config = _read_json(run_dir / 'train_config_resolved.json')
+ if config is None:
+ try:
+ import yaml
+
+ config = yaml.safe_load((run_dir / 'train_config_resolved.yaml').read_text(encoding='utf-8')) or {}
+ except Exception:
+ config = None
+ metrics = _read_metrics(run_dir / 'metrics_epoch.csv')
+ manifest = _read_json(run_dir / 'run_manifest.json')
+ latest = metrics[-1] if metrics else None
+ best = None
+ if metrics:
+ best = max(metrics, key=lambda row: float(row.get('val_payload_acc') or 0) + float(row.get('val_exact_match') or 0))
+ return {
+ 'runDir': str(run_dir),
+ 'runName': run_dir.name,
+ 'exists': run_dir.exists(),
+ 'updatedAt': time.strftime('%Y-%m-%d %H:%M:%S'),
+ 'metrics': metrics,
+ 'live': _read_live_status(run_dir / 'live_status.json'),
+ 'latest': latest,
+ 'best': best,
+ 'progress': _estimate_progress(metrics, config, run_dir) if run_dir.exists() else {},
+ 'manifest': manifest,
+ 'gpu': _gpu_status(),
+ 'processes': _training_processes(),
+ 'logs': {
+ 'stdout': _read_text(run_dir / 'stdout.log'),
+ 'stderr': _latest_launch_stderr(run_dir),
+ },
+ }
+
+
+def _latest_launch_stderr(run_dir: Path) -> str:
+ root = run_dir.parents[1] if len(run_dir.parents) >= 2 else Path('artifacts/mlwm_v1')
+ log_dir = root / 'launch_logs'
+ if not log_dir.exists():
+ return ''
+ candidates = sorted(log_dir.glob('*.err.log'), key=lambda p: p.stat().st_mtime, reverse=True)
+ return _read_text(candidates[0]) if candidates else ''
+
+
+HTML = r'''
+
+
+
+
+ MLWM Training Dashboard
+
+
+
+
+
+
+
MLWM Training Dashboard
+
Loading...
+
+ checking
+
+
+
+
+
+
+
+
+
+ Recent Epochs
| Epoch | Stage | Loss | Payload Acc | Exact | Decode | Confidence |
|---|
+
+
+
+
+
+
+'''
+
+
+class DashboardHandler(BaseHTTPRequestHandler):
+ runs_dir: Path = DEFAULT_RUNS_DIR
+ run_arg: str | None = None
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == '/api/status':
+ qs = parse_qs(parsed.query)
+ run = qs.get('run', [self.run_arg])[0]
+ self._send_json(build_status(self.runs_dir, run))
+ return
+ if parsed.path == '/run-file':
+ qs = parse_qs(parsed.query)
+ run = qs.get('run', [self.run_arg])[0]
+ rel = qs.get('path', [''])[0]
+ self._send_run_file(run, rel)
+ return
+ if parsed.path in {'/', '/index.html'}:
+ self._send_bytes(HTML.encode('utf-8'), 'text/html; charset=utf-8')
+ return
+ self.send_error(404)
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+ def _send_json(self, payload: Any) -> None:
+ self._send_bytes(json.dumps(payload, ensure_ascii=False).encode('utf-8'), 'application/json; charset=utf-8')
+
+ def _send_bytes(self, payload: bytes, content_type: str) -> None:
+ self.send_response(200)
+ self.send_header('Content-Type', content_type)
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('Content-Length', str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def _send_run_file(self, run_arg: str | None, rel_path: str) -> None:
+ run_dir = Path(run_arg) if run_arg else (_latest_run(self.runs_dir) or self.runs_dir)
+ if not run_dir.is_absolute():
+ run_dir = run_dir.resolve()
+ target = (run_dir / rel_path).resolve()
+ try:
+ target.relative_to(run_dir.resolve())
+ except ValueError:
+ self.send_error(403)
+ return
+ if not target.exists() or not target.is_file():
+ self.send_error(404)
+ return
+ content_type = mimetypes.guess_type(str(target))[0] or 'application/octet-stream'
+ self._send_bytes(target.read_bytes(), content_type)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Serve a live MLWM training dashboard')
+ parser.add_argument('--runs-dir', default=str(DEFAULT_RUNS_DIR))
+ parser.add_argument('--run', help='Specific run directory. Defaults to the newest run.')
+ parser.add_argument('--host', default='127.0.0.1')
+ parser.add_argument('--port', type=int, default=8765)
+ parser.add_argument('--open', action='store_true', help='Open the dashboard in the default browser.')
+ args = parser.parse_args()
+
+ DashboardHandler.runs_dir = Path(args.runs_dir)
+ DashboardHandler.run_arg = args.run
+ server = ThreadingHTTPServer((args.host, args.port), DashboardHandler)
+ url = f'http://{args.host}:{args.port}/'
+ print(f'MLWM dashboard: {url}', flush=True)
+ if args.open:
+ webbrowser.open(url)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/dataset.py b/blind_watermark/mlwm/dataset.py
new file mode 100644
index 0000000..cf73d0e
--- /dev/null
+++ b/blind_watermark/mlwm/dataset.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import random
+from pathlib import Path
+
+import cv2
+import numpy as np
+
+from .attacks import AttackConfig
+from .codec import PAYLOAD_BITS, encode_text_payload
+from .traceability import dataset_manifest_hash
+
+try:
+ import torch
+ from torch.utils.data import Dataset
+except ImportError: # pragma: no cover
+ torch = None
+ Dataset = object
+
+
+IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tif', '.tiff'}
+PAYLOAD_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+
+
+def require_torch_dataset():
+ if torch is None:
+ raise ImportError('PyTorch is required for MLWM dataset/training')
+ return torch
+
+
+def discover_images(root: str) -> list[str]:
+ base = Path(root)
+ if not base.exists():
+ return []
+ return [
+ str(path)
+ for path in sorted(base.rglob('*'))
+ if path.is_file() and path.suffix.lower() in IMAGE_EXTS
+ ]
+
+
+def load_rgb_image(path: str) -> np.ndarray:
+ image = cv2.imread(path, cv2.IMREAD_COLOR)
+ if image is None:
+ raise ValueError(f'cannot read image: {path}')
+ return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+
+
+def center_crop_resize(image, size: int):
+ h, w = image.shape[:2]
+ edge = min(h, w)
+ y0 = max(0, (h - edge) // 2)
+ x0 = max(0, (w - edge) // 2)
+ crop = image[y0:y0 + edge, x0:x0 + edge]
+ return cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
+
+
+def sample_payload_text(rng: random.Random, *, min_bytes: int = 6, max_bytes: int = 16) -> str:
+ target_len = rng.randint(min_bytes, max_bytes)
+ chars = [rng.choice(PAYLOAD_ALPHABET) for _ in range(target_len)]
+ return ''.join(chars)
+
+
+def build_fixed_residual_map(bits, height: int, width: int, scale: float):
+ torch_mod = require_torch_dataset()
+ bit_tensor = bits.view(16, 16) * 2.0 - 1.0
+ bit_tensor = bit_tensor.unsqueeze(0).unsqueeze(0)
+ grid = torch_mod.nn.functional.interpolate(bit_tensor, size=(height, width), mode='nearest')
+ grid = grid.repeat(1, 3, 1, 1)
+ return grid * scale
+
+
+class SyntheticPayloadImageDataset(Dataset):
+ def __init__(self, root: str, image_size: int, seed: int = 20260426) -> None:
+ require_torch_dataset()
+ self.paths = discover_images(root)
+ if not self.paths:
+ raise ValueError(f'no images found under {root}')
+ self.image_size = image_size
+ self.base_seed = seed
+ self.manifest_hash = dataset_manifest_hash(self.paths)
+
+ def __len__(self) -> int:
+ return len(self.paths)
+
+ def __getitem__(self, index: int):
+ torch_mod = require_torch_dataset()
+ rng = random.Random(self.base_seed + index)
+ path = self.paths[index]
+ image = center_crop_resize(load_rgb_image(path), self.image_size)
+ text = sample_payload_text(rng)
+ payload = encode_text_payload(text)
+ image_tensor = torch_mod.from_numpy(image).permute(2, 0, 1).float() / 255.0
+ bits_tensor = torch_mod.from_numpy(payload.bits.copy()).float()
+ if bits_tensor.numel() != PAYLOAD_BITS:
+ raise AssertionError('unexpected payload length')
+ return {
+ 'path': path,
+ 'image': image_tensor,
+ 'bits': bits_tensor,
+ 'text': text,
+ }
+
+
+def attack_config_from_dict(data: dict) -> AttackConfig:
+ return AttackConfig.from_dict(data)
diff --git a/blind_watermark/mlwm/download_unsplash_lite.py b/blind_watermark/mlwm/download_unsplash_lite.py
new file mode 100644
index 0000000..289424f
--- /dev/null
+++ b/blind_watermark/mlwm/download_unsplash_lite.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from pathlib import Path
+
+from .traceability import utc_now_iso, write_json
+
+
+USER_AGENT = 'LuminCrypt-MLWM/1.0'
+
+
+def image_url(base_url: str, width: int, quality: int) -> str:
+ parsed = urllib.parse.urlparse(base_url)
+ query = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
+ query.update({
+ 'auto': 'format',
+ 'fit': 'max',
+ 'w': str(width),
+ 'q': str(quality),
+ })
+ return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
+
+
+def iter_rows(photos_file: str, *, limit: int | None = None, min_size: int = 512):
+ with Path(photos_file).open('r', encoding='utf-8', newline='') as f:
+ reader = csv.DictReader(f, delimiter='\t')
+ count = 0
+ for row in reader:
+ try:
+ width = int(float(row.get('photo_width') or 0))
+ height = int(float(row.get('photo_height') or 0))
+ except ValueError:
+ continue
+ if min(width, height) < min_size:
+ continue
+ if not row.get('photo_id') or not row.get('photo_image_url'):
+ continue
+ yield row
+ count += 1
+ if limit is not None and count >= limit:
+ break
+
+
+def download_one(row: dict, out_dir: Path, *, width: int, quality: int, timeout: int, retries: int) -> dict:
+ photo_id = row['photo_id']
+ target = out_dir / f'{photo_id}.jpg'
+ url = image_url(row['photo_image_url'], width, quality)
+ record = {
+ 'photoId': photo_id,
+ 'target': str(target),
+ 'sourceUrl': row.get('photo_url'),
+ 'imageUrl': url,
+ 'ok': False,
+ 'error': None,
+ }
+ if target.exists() and target.stat().st_size > 0:
+ record['ok'] = True
+ record['skipped'] = True
+ record['bytes'] = target.stat().st_size
+ return record
+
+ request = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
+ last_error = None
+ for attempt in range(retries + 1):
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ data = response.read()
+ if len(data) < 1024:
+ raise ValueError('downloaded file is unexpectedly small')
+ tmp = target.with_suffix('.tmp')
+ tmp.write_bytes(data)
+ tmp.replace(target)
+ record['ok'] = True
+ record['bytes'] = len(data)
+ return record
+ except (OSError, urllib.error.URLError, ValueError) as exc:
+ last_error = str(exc)
+ if attempt < retries:
+ time.sleep(0.5 * (attempt + 1))
+ record['error'] = last_error
+ return record
+
+
+def download_dataset(
+ photos_file: str,
+ out_dir: str,
+ *,
+ limit: int | None = None,
+ min_size: int = 512,
+ width: int = 1024,
+ quality: int = 85,
+ workers: int = 8,
+ timeout: int = 45,
+ retries: int = 2,
+) -> dict:
+ out = Path(out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ rows = list(iter_rows(photos_file, limit=limit, min_size=min_size))
+ results = []
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
+ futures = [
+ executor.submit(download_one, row, out, width=width, quality=quality, timeout=timeout, retries=retries)
+ for row in rows
+ ]
+ for future in as_completed(futures):
+ results.append(future.result())
+
+ ok = sum(1 for result in results if result.get('ok'))
+ failed = len(results) - ok
+ manifest = {
+ 'createdAt': utc_now_iso(),
+ 'photosFile': str(Path(photos_file).resolve()),
+ 'outDir': str(out.resolve()),
+ 'limit': limit,
+ 'minSize': min_size,
+ 'width': width,
+ 'quality': quality,
+ 'workers': workers,
+ 'counts': {
+ 'requested': len(rows),
+ 'ok': ok,
+ 'failed': failed,
+ },
+ 'results': sorted(results, key=lambda item: item['photoId']),
+ }
+ write_json(out / 'download_manifest.json', manifest)
+ return manifest
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Download images referenced by Unsplash Lite photos.csv000')
+ parser.add_argument('--photos-file', required=True)
+ parser.add_argument('--out-dir', default='data/unsplash_lite_raw')
+ parser.add_argument('--limit', type=int)
+ parser.add_argument('--min-size', type=int, default=512)
+ parser.add_argument('--width', type=int, default=1024)
+ parser.add_argument('--quality', type=int, default=85)
+ parser.add_argument('--workers', type=int, default=8)
+ parser.add_argument('--timeout', type=int, default=45)
+ parser.add_argument('--retries', type=int, default=2)
+ args = parser.parse_args()
+
+ manifest = download_dataset(
+ args.photos_file,
+ args.out_dir,
+ limit=args.limit,
+ min_size=args.min_size,
+ width=args.width,
+ quality=args.quality,
+ workers=args.workers,
+ timeout=args.timeout,
+ retries=args.retries,
+ )
+ print(json.dumps(manifest['counts'], indent=2, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/eval_checkpoint.py b/blind_watermark/mlwm/eval_checkpoint.py
new file mode 100644
index 0000000..63de216
--- /dev/null
+++ b/blind_watermark/mlwm/eval_checkpoint.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from .dataset import SyntheticPayloadImageDataset, attack_config_from_dict
+from .metrics import bit_accuracy, decode_success_rate, exact_match_rate
+from .models import build_models, require_torch
+from .train import attack_batch, load_config
+from .traceability import utc_now_iso
+
+
+def evaluate_checkpoint(args) -> dict[str, Any]:
+ torch, _, _ = require_torch()
+ config = load_config(args.config)
+ val_root = args.val_dir or config.get('val_dir') or config['train_dir']
+ dataset = SyntheticPayloadImageDataset(val_root, int(config.get('image_size', 512)))
+ loader = torch.utils.data.DataLoader(
+ dataset,
+ batch_size=int(args.batch_size or config.get('batch_size', 4)),
+ shuffle=False,
+ num_workers=max(1, int(config.get('num_workers', 8)) // 2),
+ pin_memory=torch.cuda.is_available(),
+ drop_last=False,
+ )
+ device = torch.device(args.device or ('cuda' if torch.cuda.is_available() else 'cpu'))
+ model_cfg = config.get('model', {})
+ encoder, decoder = build_models(
+ payload_bits=int(config.get('payload_bits', 256)),
+ residual_scale=float(model_cfg.get('residual_scale', 8.0 / 255.0)),
+ )
+ checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
+ encoder.load_state_dict(checkpoint['encoder'])
+ decoder.load_state_dict(checkpoint['decoder'])
+ encoder.to(device).eval()
+ decoder.to(device).eval()
+ attack_cfg = attack_config_from_dict(config.get('attack', {}))
+
+ payload_acc = 0.0
+ exact = 0.0
+ decode = 0.0
+ batches = 0
+ samples = 0
+ repeats = max(1, int(args.repeats))
+ with torch.no_grad():
+ for _ in range(repeats):
+ for batch_index, batch in enumerate(loader):
+ if args.max_batches and batch_index >= args.max_batches:
+ break
+ clean = batch['image'].to(device)
+ bits = batch['bits'].to(device)
+ residual = encoder(clean, bits)
+ watermarked = torch.clamp(clean + residual, 0.0, 1.0)
+ attacked = attack_batch(torch, watermarked, attack_cfg, args.strength).to(device)
+ logits, _ = decoder(attacked)
+ logits_np = logits.detach().cpu().numpy()
+ bits_np = bits.detach().cpu().numpy()
+ payload_acc += bit_accuracy(logits_np, bits_np)
+ exact += exact_match_rate(logits_np, bits_np)
+ decode += decode_success_rate(logits_np, list(batch['text']))
+ batches += 1
+ samples += int(clean.shape[0])
+
+ result = {
+ 'checkpoint': str(args.checkpoint),
+ 'config': str(args.config),
+ 'valDir': str(val_root),
+ 'strength': args.strength,
+ 'repeats': repeats,
+ 'batches': batches,
+ 'samples': samples,
+ 'payloadAcc': payload_acc / max(batches, 1),
+ 'exactMatch': exact / max(batches, 1),
+ 'decodeSuccess': decode / max(batches, 1),
+ 'createdAt': utc_now_iso(),
+ }
+ if args.out:
+ out_path = Path(args.out)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
+ return result
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Evaluate an MLWM checkpoint')
+ parser.add_argument('--config', required=True)
+ parser.add_argument('--checkpoint', required=True)
+ parser.add_argument('--val-dir')
+ parser.add_argument('--strength', default='medium')
+ parser.add_argument('--repeats', type=int, default=3)
+ parser.add_argument('--max-batches', type=int)
+ parser.add_argument('--batch-size', type=int)
+ parser.add_argument('--device')
+ parser.add_argument('--out')
+ args = parser.parse_args()
+ print(json.dumps(evaluate_checkpoint(args), ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/export_onnx.py b/blind_watermark/mlwm/export_onnx.py
new file mode 100644
index 0000000..1fdea1e
--- /dev/null
+++ b/blind_watermark/mlwm/export_onnx.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .codec import PAYLOAD_BITS
+from .infer import probe_runtime
+from .models import build_models, require_torch
+from .traceability import config_hash, git_snapshot, sha256_file, stable_json_dumps, utc_now_iso, write_json
+
+
+def configure_console_encoding() -> None:
+ for stream_name in ('stdout', 'stderr'):
+ stream = getattr(sys, stream_name, None)
+ if hasattr(stream, 'reconfigure'):
+ stream.reconfigure(encoding='utf-8', errors='replace')
+
+
+def load_config(path: str) -> dict[str, Any]:
+ cfg_path = Path(path)
+ data = yaml.safe_load(cfg_path.read_text(encoding='utf-8')) or {}
+ parent = data.pop('extends', None)
+ if parent:
+ parent_path = Path(parent)
+ if not parent_path.is_absolute():
+ candidate = (cfg_path.parent / parent_path).resolve()
+ parent_path = candidate if candidate.exists() else Path(parent).resolve()
+ parent_data = load_config(str(parent_path))
+ parent_data.update(data)
+ return parent_data
+ return data
+
+
+def resolve_export_image_size(config: dict[str, Any]) -> int:
+ requested = int(config.get('export', {}).get('image_size') or config.get('image_size', 512))
+ if requested % 128 == 0:
+ return requested
+ return ((requested + 127) // 128) * 128
+
+
+def export_models(config: dict[str, Any], checkpoint_path: str, out_dir: str | None = None) -> dict[str, Any]:
+ torch, _, _ = require_torch()
+ ckpt = torch.load(checkpoint_path, map_location='cpu')
+ model_cfg = config.get('model', {})
+ encoder, decoder = build_models(
+ payload_bits=PAYLOAD_BITS,
+ residual_scale=float(model_cfg.get('residual_scale', 8.0 / 255.0)),
+ )
+ encoder.load_state_dict(ckpt['encoder'])
+ decoder.load_state_dict(ckpt['decoder'])
+ encoder.eval()
+ decoder.eval()
+
+ models_dir = Path(out_dir or config.get('models_dir', 'resources/models/neural_wm'))
+ models_dir.mkdir(parents=True, exist_ok=True)
+ encoder_path = models_dir / 'encoder.onnx'
+ decoder_path = models_dir / 'decoder.onnx'
+
+ trained_image_size = int(config.get('image_size', 512))
+ export_image_size = resolve_export_image_size(config)
+ dummy_image = torch.randn(1, 3, export_image_size, export_image_size)
+ dummy_bits = torch.randint(0, 2, (1, PAYLOAD_BITS)).float()
+
+ torch.onnx.export(
+ encoder,
+ (dummy_image, dummy_bits),
+ str(encoder_path),
+ input_names=['image', 'payload_bits'],
+ output_names=['residual'],
+ dynamic_axes=None,
+ opset_version=int(config.get('export', {}).get('opset', 18)),
+ external_data=False,
+ )
+ torch.onnx.export(
+ decoder,
+ dummy_image,
+ str(decoder_path),
+ input_names=['image'],
+ output_names=['payload_logits', 'confidence'],
+ dynamic_axes=None,
+ opset_version=int(config.get('export', {}).get('opset', 18)),
+ external_data=False,
+ )
+
+ manifest = {
+ 'modelVersion': 'mlwm-v1-export',
+ 'status': 'ready',
+ 'engine': 'neural',
+ 'imageSize': export_image_size,
+ 'trainedImageSize': trained_image_size,
+ 'trainingConfigId': config_hash(config),
+ 'datasetManifestHash': ckpt.get('datasetManifestHash'),
+ 'exportTime': utc_now_iso(),
+ 'gitCommit': git_snapshot().get('commit'),
+ 'checkpoint': {
+ 'path': str(checkpoint_path),
+ 'sha256': sha256_file(checkpoint_path),
+ 'bestEpoch': ckpt.get('bestEpoch'),
+ 'bestMetric': ckpt.get('bestMetric'),
+ },
+ 'encoder': {
+ 'path': encoder_path.name,
+ 'sha256': sha256_file(encoder_path),
+ },
+ 'decoder': {
+ 'path': decoder_path.name,
+ 'sha256': sha256_file(decoder_path),
+ },
+ 'benchmarkSummary': ckpt.get('benchmarkSummary', {}),
+ }
+ write_json(models_dir / 'model.json', manifest)
+ return {
+ 'encoder': str(encoder_path),
+ 'decoder': str(decoder_path),
+ 'manifest': manifest,
+ 'runtimeStatus': probe_runtime(str(models_dir)).ready,
+ }
+
+
+def main() -> None:
+ configure_console_encoding()
+ parser = argparse.ArgumentParser(description='Export MLWM v1 models to ONNX')
+ parser.add_argument('--config', required=True)
+ parser.add_argument('--checkpoint', required=True)
+ parser.add_argument('--out-dir')
+ args = parser.parse_args()
+
+ config = load_config(args.config)
+ result = export_models(config, args.checkpoint, args.out_dir)
+ print(stable_json_dumps(result))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/infer.py b/blind_watermark/mlwm/infer.py
new file mode 100644
index 0000000..b265b48
--- /dev/null
+++ b/blind_watermark/mlwm/infer.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+import cv2
+import numpy as np
+
+from .codec import PAYLOAD_BITS, decode_payload_logits
+
+try:
+ import onnxruntime as ort
+except ImportError: # pragma: no cover
+ ort = None
+
+
+class NeuralRuntimeUnavailable(RuntimeError):
+ pass
+
+
+@dataclass
+class NeuralModelStatus:
+ models_dir: Path
+ manifest: dict[str, Any]
+ encoder_path: Path
+ decoder_path: Path
+ runtime_available: bool
+ models_available: bool
+ ready: bool
+
+
+def resolve_models_dir(models_dir: str | None = None) -> Path:
+ return Path(models_dir or 'resources/models/neural_wm').resolve()
+
+
+def load_model_manifest(models_dir: str | None = None) -> dict[str, Any]:
+ model_dir = resolve_models_dir(models_dir)
+ manifest_path = model_dir / 'model.json'
+ if not manifest_path.exists():
+ return {'status': 'missing-manifest'}
+ return json.loads(manifest_path.read_text(encoding='utf-8'))
+
+
+def probe_runtime(models_dir: str | None = None) -> NeuralModelStatus:
+ model_dir = resolve_models_dir(models_dir)
+ manifest = load_model_manifest(str(model_dir))
+ encoder_path = model_dir / manifest.get('encoder', {}).get('path', 'encoder.onnx')
+ decoder_path = model_dir / manifest.get('decoder', {}).get('path', 'decoder.onnx')
+ runtime_available = ort is not None
+ models_available = encoder_path.exists() and decoder_path.exists()
+ return NeuralModelStatus(
+ models_dir=model_dir,
+ manifest=manifest,
+ encoder_path=encoder_path,
+ decoder_path=decoder_path,
+ runtime_available=runtime_available,
+ models_available=models_available,
+ ready=runtime_available and models_available,
+ )
+
+
+def _session_providers(use_cuda: bool) -> list[str]:
+ if ort is None:
+ return []
+ available = ort.get_available_providers()
+ if use_cuda and 'CUDAExecutionProvider' in available:
+ return ['CUDAExecutionProvider', 'CPUExecutionProvider']
+ return ['CPUExecutionProvider']
+
+
+@lru_cache(maxsize=4)
+def _load_encoder_session(path: str, use_cuda: bool):
+ if ort is None:
+ raise NeuralRuntimeUnavailable('onnxruntime is not installed')
+ return ort.InferenceSession(path, providers=_session_providers(use_cuda))
+
+
+@lru_cache(maxsize=4)
+def _load_decoder_session(path: str, use_cuda: bool):
+ if ort is None:
+ raise NeuralRuntimeUnavailable('onnxruntime is not installed')
+ return ort.InferenceSession(path, providers=_session_providers(use_cuda))
+
+
+def _prepare_image(image_rgb: np.ndarray, image_size: int = 512) -> np.ndarray:
+ resized = cv2.resize(image_rgb, (image_size, image_size), interpolation=cv2.INTER_AREA)
+ return (resized.astype(np.float32) / 255.0).transpose(2, 0, 1)[None, ...]
+
+
+def _manifest_image_size(manifest: dict[str, Any]) -> int:
+ return int(manifest.get('imageSize') or 512)
+
+
+def _resize_residual(residual: np.ndarray, shape: tuple[int, int]) -> np.ndarray:
+ h, w = shape
+ residual = residual.squeeze(0).transpose(1, 2, 0)
+ return cv2.resize(residual, (w, h), interpolation=cv2.INTER_CUBIC)
+
+
+def _bits_array(payload_bits: np.ndarray) -> np.ndarray:
+ bits = np.asarray(payload_bits, dtype=np.float32).reshape(1, PAYLOAD_BITS)
+ if bits.shape[1] != PAYLOAD_BITS:
+ raise ValueError(f'expected {PAYLOAD_BITS} payload bits')
+ return bits
+
+
+def neural_encode_residual(
+ image_rgb: np.ndarray,
+ payload_bits: np.ndarray,
+ *,
+ models_dir: str | None = None,
+ use_cuda: bool = False,
+) -> dict[str, Any]:
+ status = probe_runtime(models_dir)
+ if not status.ready:
+ raise NeuralRuntimeUnavailable('neural models are not ready')
+ session = _load_encoder_session(str(status.encoder_path), use_cuda)
+ image_input = _prepare_image(image_rgb, _manifest_image_size(status.manifest))
+ payload_input = _bits_array(payload_bits)
+ outputs = session.run(None, {'image': image_input, 'payload_bits': payload_input})
+ residual = np.asarray(outputs[0], dtype=np.float32)
+ upsampled = _resize_residual(residual, image_rgb.shape[:2])
+ return {
+ 'residual': upsampled,
+ 'manifest': status.manifest,
+ 'modelVersion': status.manifest.get('modelVersion'),
+ }
+
+
+def apply_neural_residual(
+ image_rgb: np.ndarray,
+ residual: np.ndarray,
+ *,
+ strength: float = 1.0,
+) -> np.ndarray:
+ out = image_rgb.astype(np.float32) / 255.0
+ out = np.clip(out + residual.astype(np.float32) * strength, 0.0, 1.0)
+ return np.clip(np.round(out * 255.0), 0, 255).astype(np.uint8)
+
+
+def neural_decode_views(
+ views_rgb: list[np.ndarray],
+ *,
+ models_dir: str | None = None,
+ use_cuda: bool = False,
+) -> dict[str, Any]:
+ status = probe_runtime(models_dir)
+ if not status.ready:
+ raise NeuralRuntimeUnavailable('neural models are not ready')
+ session = _load_decoder_session(str(status.decoder_path), use_cuda)
+ attempts: list[dict[str, Any]] = []
+ weighted_logits = np.zeros(PAYLOAD_BITS, dtype=np.float32)
+ total_weight = 0.0
+ for index, view in enumerate(views_rgb):
+ outputs = session.run(None, {'image': _prepare_image(view, _manifest_image_size(status.manifest))})
+ logits = np.asarray(outputs[0], dtype=np.float32).reshape(-1)
+ confidence_logit = float(np.asarray(outputs[1], dtype=np.float32).reshape(-1)[0])
+ confidence = 1.0 / (1.0 + np.exp(-confidence_logit))
+ attempts.append({
+ 'index': index,
+ 'logits': logits,
+ 'confidence': confidence,
+ })
+ weighted_logits += logits * confidence
+ total_weight += confidence
+ try:
+ decoded = decode_payload_logits(logits)
+ decoded['confidence'] = confidence
+ decoded['attemptIndex'] = index
+ decoded['strategy'] = 'single-view'
+ decoded['manifest'] = status.manifest
+ return decoded
+ except Exception:
+ continue
+
+ if total_weight <= 1e-8:
+ raise NeuralRuntimeUnavailable('decoder produced no usable confidence scores')
+
+ aggregated_logits = weighted_logits / total_weight
+ decoded = decode_payload_logits(aggregated_logits)
+ decoded['confidence'] = float(total_weight / max(len(views_rgb), 1))
+ decoded['attempts'] = [{'index': a['index'], 'confidence': a['confidence']} for a in attempts]
+ decoded['strategy'] = 'weighted-aggregate'
+ decoded['manifest'] = status.manifest
+ return decoded
diff --git a/blind_watermark/mlwm/metrics.py b/blind_watermark/mlwm/metrics.py
new file mode 100644
index 0000000..2e59231
--- /dev/null
+++ b/blind_watermark/mlwm/metrics.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import math
+
+import cv2
+import numpy as np
+
+from .codec import decode_payload_bits
+
+
+def psnr(a: np.ndarray, b: np.ndarray) -> float:
+ arr_a = np.asarray(a, dtype=np.float32)
+ arr_b = np.asarray(b, dtype=np.float32)
+ mse = float(np.mean((arr_a - arr_b) ** 2))
+ if mse <= 1e-12:
+ return 99.0
+ return 20.0 * math.log10(255.0 / math.sqrt(mse))
+
+
+def ssim(a: np.ndarray, b: np.ndarray) -> float:
+ arr_a = np.asarray(a, dtype=np.float32)
+ arr_b = np.asarray(b, dtype=np.float32)
+ if arr_a.ndim == 3:
+ arr_a = cv2.cvtColor(arr_a.astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float32)
+ arr_b = cv2.cvtColor(arr_b.astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float32)
+ c1 = (0.01 * 255) ** 2
+ c2 = (0.03 * 255) ** 2
+ mu_a = cv2.GaussianBlur(arr_a, (11, 11), 1.5)
+ mu_b = cv2.GaussianBlur(arr_b, (11, 11), 1.5)
+ mu_a2 = mu_a * mu_a
+ mu_b2 = mu_b * mu_b
+ mu_ab = mu_a * mu_b
+ sigma_a2 = cv2.GaussianBlur(arr_a * arr_a, (11, 11), 1.5) - mu_a2
+ sigma_b2 = cv2.GaussianBlur(arr_b * arr_b, (11, 11), 1.5) - mu_b2
+ sigma_ab = cv2.GaussianBlur(arr_a * arr_b, (11, 11), 1.5) - mu_ab
+ num = (2 * mu_ab + c1) * (2 * sigma_ab + c2)
+ den = (mu_a2 + mu_b2 + c1) * (sigma_a2 + sigma_b2 + c2)
+ return float(np.mean(num / np.maximum(den, 1e-6)))
+
+
+def bit_accuracy(logits: np.ndarray, target_bits: np.ndarray) -> float:
+ logits = np.asarray(logits)
+ targets = np.asarray(target_bits)
+ predicted = (logits >= 0).astype(np.float32)
+ return float(np.mean(predicted == targets))
+
+
+def exact_match_rate(logits: np.ndarray, target_bits: np.ndarray) -> float:
+ logits = np.asarray(logits)
+ targets = np.asarray(target_bits)
+ predicted = (logits >= 0).astype(np.float32)
+ if predicted.ndim == 1:
+ return float(np.all(predicted == targets))
+ return float(np.mean(np.all(predicted == targets, axis=1)))
+
+
+def decode_success_rate(logits: np.ndarray, texts: list[str] | tuple[str, ...]) -> float:
+ logits = np.asarray(logits)
+ predicted = (logits >= 0).astype(np.float32)
+ if predicted.ndim == 1:
+ predicted = predicted.reshape(1, -1)
+ ok = 0
+ total = min(len(predicted), len(texts))
+ if total <= 0:
+ return 0.0
+ for bits, text in zip(predicted[:total], texts[:total]):
+ try:
+ decoded = decode_payload_bits(bits)
+ except Exception:
+ continue
+ ok += int(decoded.get('text') == text)
+ return float(ok / total)
diff --git a/blind_watermark/mlwm/models.py b/blind_watermark/mlwm/models.py
new file mode 100644
index 0000000..dc19ac1
--- /dev/null
+++ b/blind_watermark/mlwm/models.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+from typing import Any
+
+try:
+ import torch
+ import torch.nn.functional as F
+ from torch import nn
+except ImportError: # pragma: no cover - exercised when runtime env lacks torch
+ torch = None
+ nn = None
+ F = None
+
+
+def require_torch() -> tuple[Any, Any, Any]:
+ if torch is None or nn is None or F is None:
+ raise ImportError('PyTorch is required for MLWM training/export')
+ return torch, nn, F
+
+
+if nn is not None:
+ class ConvBlock(nn.Module):
+ def __init__(self, in_channels: int, out_channels: int) -> None:
+ super().__init__()
+ self.net = nn.Sequential(
+ nn.Conv2d(in_channels, out_channels, 3, padding=1),
+ nn.BatchNorm2d(out_channels),
+ nn.GELU(),
+ nn.Conv2d(out_channels, out_channels, 3, padding=1),
+ nn.BatchNorm2d(out_channels),
+ nn.GELU(),
+ )
+
+ def forward(self, x):
+ return self.net(x)
+
+
+ class DownBlock(nn.Module):
+ def __init__(self, in_channels: int, out_channels: int) -> None:
+ super().__init__()
+ self.pool = nn.MaxPool2d(2)
+ self.block = ConvBlock(in_channels, out_channels)
+
+ def forward(self, x):
+ return self.block(self.pool(x))
+
+
+ class UpBlock(nn.Module):
+ def __init__(self, in_channels: int, skip_channels: int, out_channels: int) -> None:
+ super().__init__()
+ self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False)
+ self.block = ConvBlock(in_channels + skip_channels, out_channels)
+
+ def forward(self, x, skip):
+ x = self.up(x)
+ if x.shape[-2:] != skip.shape[-2:]:
+ x = F.interpolate(x, size=skip.shape[-2:], mode='bilinear', align_corners=False)
+ x = torch.cat([x, skip], dim=1)
+ return self.block(x)
+
+
+ class EncoderNet(nn.Module):
+ def __init__(self, payload_bits: int = 256, residual_scale: float = 8.0 / 255.0) -> None:
+ super().__init__()
+ self.payload_bits = payload_bits
+ self.residual_scale = residual_scale
+ self.payload_mlp = nn.Sequential(
+ nn.Linear(payload_bits, 128),
+ nn.GELU(),
+ nn.Linear(128, 64),
+ nn.GELU(),
+ )
+ self.enc1 = ConvBlock(4, 32)
+ self.enc2 = DownBlock(32, 64)
+ self.enc3 = DownBlock(64, 96)
+ self.enc4 = DownBlock(96, 128)
+ self.bottleneck = ConvBlock(128 + 64, 128)
+ self.up3 = UpBlock(128 + 64, 96, 96)
+ self.up2 = UpBlock(96 + 64, 64, 64)
+ self.up1 = UpBlock(128, 32, 32)
+ self.head = nn.Sequential(
+ nn.Conv2d(32, 16, 3, padding=1),
+ nn.GELU(),
+ nn.Conv2d(16, 3, 1),
+ nn.Tanh(),
+ )
+
+ def _payload_map(self, payload_bits, height: int, width: int):
+ latent = self.payload_mlp(payload_bits)
+ return latent[:, :, None, None].expand(-1, -1, height, width)
+
+ def _payload_grid(self, payload_bits, height: int, width: int):
+ grid = payload_bits.view(-1, 1, 16, 16) * 2.0 - 1.0
+ return F.interpolate(grid, size=(height, width), mode='nearest')
+
+ def forward(self, image, payload_bits):
+ p0 = self._payload_grid(payload_bits, image.shape[-2], image.shape[-1])
+ x1 = self.enc1(torch.cat([image, p0], dim=1))
+ x2 = self.enc2(x1)
+ x3 = self.enc3(x2)
+ x4 = self.enc4(x3)
+ p4 = self._payload_map(payload_bits, x4.shape[-2], x4.shape[-1])
+ b = self.bottleneck(torch.cat([x4, p4], dim=1))
+ p3 = self._payload_map(payload_bits, x3.shape[-2], x3.shape[-1])
+ u3 = self.up3(torch.cat([b, self._payload_map(payload_bits, b.shape[-2], b.shape[-1])], dim=1), x3)
+ u3 = torch.cat([u3, p3], dim=1)
+ u2 = self.up2(u3, x2)
+ u2 = torch.cat([u2, self._payload_map(payload_bits, u2.shape[-2], u2.shape[-1])], dim=1)
+ u1 = self.up1(u2, x1)
+ residual = self.head(u1) * self.residual_scale
+ return residual
+
+
+ class DecoderNet(nn.Module):
+ def __init__(self, payload_bits: int = 256) -> None:
+ super().__init__()
+ if payload_bits != 256:
+ raise ValueError('DecoderNet v1 expects a 16x16 payload grid')
+ self.payload_bits = payload_bits
+ self.features = nn.Sequential(
+ ConvBlock(3, 32),
+ nn.MaxPool2d(2),
+ ConvBlock(32, 64),
+ nn.MaxPool2d(2),
+ ConvBlock(64, 96),
+ nn.MaxPool2d(2),
+ ConvBlock(96, 128),
+ )
+ self.spatial_pool = nn.AdaptiveAvgPool2d((16, 16))
+ self.payload_head = nn.Sequential(
+ nn.Conv2d(128, 64, 3, padding=1),
+ nn.GELU(),
+ nn.Conv2d(64, 1, 1),
+ )
+ self.confidence_head = nn.Sequential(
+ nn.AdaptiveAvgPool2d((1, 1)),
+ nn.Flatten(),
+ nn.Linear(128, 256),
+ nn.GELU(),
+ nn.Linear(256, 1),
+ )
+
+ def forward(self, image):
+ feat = self.features(image)
+ spatial = self.spatial_pool(feat)
+ logits = self.payload_head(spatial).flatten(1)
+ return logits, self.confidence_head(feat)
+
+
+ def build_models(payload_bits: int = 256, residual_scale: float = 8.0 / 255.0):
+ return EncoderNet(payload_bits=payload_bits, residual_scale=residual_scale), DecoderNet(payload_bits=payload_bits)
+else:
+ class EncoderNet: # pragma: no cover - placeholder when torch missing
+ def __init__(self, *args, **kwargs) -> None:
+ raise ImportError('PyTorch is required for MLWM training/export')
+
+
+ class DecoderNet:
+ def __init__(self, *args, **kwargs) -> None:
+ raise ImportError('PyTorch is required for MLWM training/export')
+
+
+ def build_models(*args, **kwargs):
+ raise ImportError('PyTorch is required for MLWM training/export')
diff --git a/blind_watermark/mlwm/prepare_dataset.py b/blind_watermark/mlwm/prepare_dataset.py
new file mode 100644
index 0000000..5c8dd30
--- /dev/null
+++ b/blind_watermark/mlwm/prepare_dataset.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import shutil
+from dataclasses import dataclass
+from hashlib import sha256
+from pathlib import Path
+from typing import Iterable
+
+import cv2
+
+from .dataset import IMAGE_EXTS
+from .traceability import utc_now_iso, write_json
+
+
+@dataclass(frozen=True)
+class ImageCandidate:
+ path: Path
+ sha256: str
+ width: int
+ height: int
+
+
+def iter_image_paths(sources: Iterable[str]) -> list[Path]:
+ paths: list[Path] = []
+ for source in sources:
+ root = Path(source)
+ if root.is_file() and root.suffix.lower() in IMAGE_EXTS:
+ paths.append(root)
+ elif root.is_dir():
+ paths.extend(
+ path
+ for path in root.rglob('*')
+ if path.is_file() and path.suffix.lower() in IMAGE_EXTS
+ )
+ return sorted({path.resolve() for path in paths})
+
+
+def sha256_path(path: Path) -> str:
+ digest = sha256()
+ with path.open('rb') as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b''):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def inspect_image(path: Path, min_size: int) -> ImageCandidate | None:
+ image = cv2.imread(str(path), cv2.IMREAD_COLOR)
+ if image is None:
+ return None
+ h, w = image.shape[:2]
+ if min(h, w) < min_size:
+ return None
+ return ImageCandidate(path=path, sha256=sha256_path(path), width=w, height=h)
+
+
+def materialize(candidate: ImageCandidate, target_dir: Path, index: int, copy_mode: str) -> Path:
+ ext = candidate.path.suffix.lower()
+ target = target_dir / f'{index:06d}_{candidate.sha256[:12]}{ext}'
+ if target.exists():
+ return target
+ if copy_mode == 'hardlink':
+ try:
+ target.hardlink_to(candidate.path)
+ return target
+ except OSError:
+ pass
+ shutil.copy2(candidate.path, target)
+ return target
+
+
+def prepare_dataset(
+ sources: list[str],
+ out_dir: str,
+ *,
+ val_ratio: float = 0.1,
+ min_size: int = 512,
+ limit: int | None = None,
+ seed: int = 20260426,
+ copy_mode: str = 'copy',
+ clean: bool = False,
+) -> dict:
+ if not 0.0 < val_ratio < 0.5:
+ raise ValueError('val_ratio must be between 0 and 0.5')
+ if min_size <= 0:
+ raise ValueError('min_size must be positive')
+ if copy_mode not in {'copy', 'hardlink'}:
+ raise ValueError('copy_mode must be copy or hardlink')
+
+ out = Path(out_dir)
+ train_dir = out / 'train_images'
+ val_dir = out / 'val_images'
+ if clean:
+ for target_dir in (train_dir, val_dir):
+ if target_dir.exists():
+ shutil.rmtree(target_dir)
+ train_dir.mkdir(parents=True, exist_ok=True)
+ val_dir.mkdir(parents=True, exist_ok=True)
+
+ seen: set[str] = set()
+ candidates: list[ImageCandidate] = []
+ rejected = 0
+ for path in iter_image_paths(sources):
+ candidate = inspect_image(path, min_size)
+ if candidate is None:
+ rejected += 1
+ continue
+ if candidate.sha256 in seen:
+ continue
+ seen.add(candidate.sha256)
+ candidates.append(candidate)
+
+ rng = random.Random(seed)
+ rng.shuffle(candidates)
+ if limit is not None:
+ candidates = candidates[:max(0, limit)]
+
+ val_count = max(1, int(round(len(candidates) * val_ratio))) if len(candidates) > 1 else 0
+ val_set = candidates[:val_count]
+ train_set = candidates[val_count:]
+
+ train_records = []
+ for index, candidate in enumerate(train_set, start=1):
+ target = materialize(candidate, train_dir, index, copy_mode)
+ train_records.append(record_for(candidate, target, train_dir))
+
+ val_records = []
+ for index, candidate in enumerate(val_set, start=1):
+ target = materialize(candidate, val_dir, index, copy_mode)
+ val_records.append(record_for(candidate, target, val_dir))
+
+ manifest = {
+ 'createdAt': utc_now_iso(),
+ 'sources': sources,
+ 'outDir': str(out.resolve()),
+ 'seed': seed,
+ 'minSize': min_size,
+ 'valRatio': val_ratio,
+ 'copyMode': copy_mode,
+ 'clean': clean,
+ 'rejected': rejected,
+ 'counts': {
+ 'train': len(train_records),
+ 'val': len(val_records),
+ 'total': len(train_records) + len(val_records),
+ },
+ 'train': train_records,
+ 'val': val_records,
+ }
+ write_json(out / 'dataset_manifest.json', manifest)
+ return manifest
+
+
+def record_for(candidate: ImageCandidate, target: Path, base: Path) -> dict:
+ return {
+ 'path': str(target.relative_to(base.parent)).replace('\\', '/'),
+ 'source': str(candidate.path),
+ 'sha256': candidate.sha256,
+ 'width': candidate.width,
+ 'height': candidate.height,
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Prepare MLWM image dataset directories')
+ parser.add_argument('--source', action='append', required=True, help='Source image file or directory. Repeatable.')
+ parser.add_argument('--out-dir', default='data')
+ parser.add_argument('--val-ratio', type=float, default=0.1)
+ parser.add_argument('--min-size', type=int, default=512)
+ parser.add_argument('--limit', type=int)
+ parser.add_argument('--seed', type=int, default=20260426)
+ parser.add_argument('--copy-mode', choices=['copy', 'hardlink'], default='copy')
+ parser.add_argument('--clean', action='store_true', help='Remove existing train_images/val_images before preparing.')
+ args = parser.parse_args()
+
+ manifest = prepare_dataset(
+ args.source,
+ args.out_dir,
+ val_ratio=args.val_ratio,
+ min_size=args.min_size,
+ limit=args.limit,
+ seed=args.seed,
+ copy_mode=args.copy_mode,
+ clean=args.clean,
+ )
+ print(json.dumps(manifest['counts'], indent=2, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/mlwm/traceability.py b/blind_watermark/mlwm/traceability.py
new file mode 100644
index 0000000..165111b
--- /dev/null
+++ b/blind_watermark/mlwm/traceability.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import platform
+import subprocess
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+def utc_now_iso() -> str:
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def sha256_file(path: str | os.PathLike[str]) -> str:
+ h = hashlib.sha256()
+ with open(path, 'rb') as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b''):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def stable_json_dumps(obj: Any) -> str:
+ return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
+
+
+def config_hash(config: dict[str, Any]) -> str:
+ return sha256_bytes(stable_json_dumps(config).encode('utf-8'))
+
+
+def write_json(path: str | os.PathLike[str], payload: Any) -> None:
+ target = Path(path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
+
+
+def _git_output(args: list[str], cwd: str | None = None) -> str | None:
+ try:
+ return subprocess.check_output(args, cwd=cwd, stderr=subprocess.DEVNULL).decode('utf-8').strip()
+ except Exception:
+ return None
+
+
+def git_snapshot(repo_root: str | None = None) -> dict[str, Any]:
+ root = repo_root or os.getcwd()
+ branch = _git_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=root)
+ commit = _git_output(['git', 'rev-parse', 'HEAD'], cwd=root)
+ short = _git_output(['git', 'rev-parse', '--short', 'HEAD'], cwd=root)
+ dirty = _git_output(['git', 'status', '--porcelain'], cwd=root)
+ return {
+ 'branch': branch,
+ 'commit': commit,
+ 'shortCommit': short,
+ 'dirty': bool(dirty),
+ }
+
+
+def gpu_snapshot() -> dict[str, Any]:
+ query = [
+ 'nvidia-smi',
+ '--query-gpu=name,driver_version,memory.total',
+ '--format=csv,noheader,nounits',
+ ]
+ try:
+ row = subprocess.check_output(query, stderr=subprocess.DEVNULL).decode('utf-8').strip().splitlines()[0]
+ name, driver, memory_mb = [part.strip() for part in row.split(',')]
+ return {
+ 'name': name,
+ 'driverVersion': driver,
+ 'memoryMB': int(memory_mb),
+ }
+ except Exception:
+ return {
+ 'name': None,
+ 'driverVersion': None,
+ 'memoryMB': None,
+ }
+
+
+def environment_snapshot() -> dict[str, Any]:
+ return {
+ 'pythonVersion': platform.python_version(),
+ 'platform': platform.platform(),
+ 'cudaVersion': _git_output(['nvidia-smi'], cwd=None),
+ 'gpu': gpu_snapshot(),
+ 'timestamp': utc_now_iso(),
+ }
+
+
+def dataset_manifest_hash(paths: list[str]) -> str:
+ normalized = [str(Path(p).as_posix()) for p in sorted(paths)]
+ return sha256_bytes('\n'.join(normalized).encode('utf-8'))
+
+
+def build_run_manifest(
+ *,
+ repo_root: str,
+ config: dict[str, Any],
+ train_dir: str,
+ val_dir: str,
+ dataset_hash: str,
+ output_dir: str,
+ promoted: bool = False,
+ extra: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ manifest = {
+ 'git': git_snapshot(repo_root),
+ 'environment': environment_snapshot(),
+ 'trainDir': train_dir,
+ 'valDir': val_dir,
+ 'datasetManifestHash': dataset_hash,
+ 'configHash': config_hash(config),
+ 'outputDir': output_dir,
+ 'promoted': promoted,
+ 'createdAt': utc_now_iso(),
+ }
+ if extra:
+ manifest.update(extra)
+ return manifest
diff --git a/blind_watermark/mlwm/train.py b/blind_watermark/mlwm/train.py
new file mode 100644
index 0000000..e3e02b6
--- /dev/null
+++ b/blind_watermark/mlwm/train.py
@@ -0,0 +1,501 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import os
+import random
+import time
+from pathlib import Path
+from typing import Any
+
+import cv2
+import numpy as np
+import yaml
+
+from .attacks import apply_random_attack_chain
+from .dataset import SyntheticPayloadImageDataset, attack_config_from_dict, build_fixed_residual_map
+from .metrics import bit_accuracy, decode_success_rate, exact_match_rate
+from .models import build_models, require_torch
+from .traceability import build_run_manifest, git_snapshot, utc_now_iso, write_json
+
+
+def load_config(path: str) -> dict[str, Any]:
+ cfg_path = Path(path)
+ data = yaml.safe_load(cfg_path.read_text(encoding='utf-8')) or {}
+ parent = data.pop('extends', None)
+ if parent:
+ parent_path = Path(parent)
+ if not parent_path.is_absolute():
+ candidate = (cfg_path.parent / parent_path).resolve()
+ parent_path = candidate if candidate.exists() else Path(parent).resolve()
+ base = load_config(str(parent_path))
+ return deep_update(base, data)
+ return data
+
+
+def deep_update(base: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
+ out = dict(base)
+ for key, value in updates.items():
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
+ out[key] = deep_update(out[key], value)
+ else:
+ out[key] = value
+ return out
+
+
+def resolve_cli_overrides(config: dict[str, Any], args) -> dict[str, Any]:
+ out = dict(config)
+ if args.train_dir:
+ out['train_dir'] = args.train_dir
+ if args.val_dir:
+ out['val_dir'] = args.val_dir
+ if args.workdir:
+ out['artifacts_dir'] = args.workdir
+ if args.image_size:
+ out['image_size'] = args.image_size
+ if args.batch_size:
+ out['batch_size'] = args.batch_size
+ if args.grad_accum:
+ out['grad_accum'] = args.grad_accum
+ if args.device:
+ out['device'] = args.device
+ if args.checkpoint:
+ out['checkpoint'] = args.checkpoint
+ if args.amp:
+ out['amp'] = True
+ if args.epochs:
+ out.setdefault('stages', {}).setdefault('main', {})['epochs'] = args.epochs
+ return out
+
+
+def torch_ssim(torch_mod, image_a, image_b):
+ c1 = (0.01 ** 2)
+ c2 = (0.03 ** 2)
+ mu_a = torch_mod.nn.functional.avg_pool2d(image_a, 3, 1, 1)
+ mu_b = torch_mod.nn.functional.avg_pool2d(image_b, 3, 1, 1)
+ sigma_a = torch_mod.nn.functional.avg_pool2d(image_a * image_a, 3, 1, 1) - mu_a * mu_a
+ sigma_b = torch_mod.nn.functional.avg_pool2d(image_b * image_b, 3, 1, 1) - mu_b * mu_b
+ sigma_ab = torch_mod.nn.functional.avg_pool2d(image_a * image_b, 3, 1, 1) - mu_a * mu_b
+ ssim_map = ((2 * mu_a * mu_b + c1) * (2 * sigma_ab + c2)) / (
+ (mu_a * mu_a + mu_b * mu_b + c1) * (sigma_a + sigma_b + c2) + 1e-6
+ )
+ return ssim_map.mean()
+
+
+def total_variation(torch_mod, image):
+ return (
+ torch_mod.abs(image[:, :, 1:, :] - image[:, :, :-1, :]).mean() +
+ torch_mod.abs(image[:, :, :, 1:] - image[:, :, :, :-1]).mean()
+ )
+
+
+def fixed_residual_batch(torch_mod, bits, height: int, width: int, scale: float, device):
+ return torch_mod.stack(
+ [
+ build_fixed_residual_map(sample, height, width, scale).squeeze(0)
+ for sample in bits
+ ],
+ dim=0,
+ ).to(device)
+
+
+def attack_batch(torch_mod, watermarked, attack_cfg, stage_strength: str):
+ if stage_strength in {'clean', 'none', 'off'}:
+ return watermarked
+ attacked = []
+ for sample in watermarked.detach().cpu().numpy():
+ rgb = np.clip(np.round(sample.transpose(1, 2, 0) * 255.0), 0, 255).astype(np.uint8)
+ attacked_rgb = apply_random_attack_chain(rgb, config=attack_cfg, strength=stage_strength)
+ attacked_rgb = np.ascontiguousarray(attacked_rgb)
+ attacked.append(torch_mod.from_numpy(attacked_rgb).permute(2, 0, 1).float() / 255.0)
+ attacked_tensor = torch_mod.stack(attacked, dim=0).to(watermarked.device)
+ return watermarked + (attacked_tensor - watermarked).detach()
+
+
+def save_checkpoint(path: Path, payload: dict[str, Any], torch_mod) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ torch_mod.save(payload, path)
+
+
+def tensor_rgb_to_bgr_uint8(tensor) -> np.ndarray:
+ sample = tensor.detach().float().cpu().numpy()
+ if sample.ndim == 4:
+ sample = sample[0]
+ rgb = np.clip(np.round(sample.transpose(1, 2, 0) * 255.0), 0, 255).astype(np.uint8)
+ return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
+
+
+def write_live_status(
+ run_dir: Path,
+ *,
+ status: dict[str, Any],
+ clean=None,
+ watermarked=None,
+ attacked=None,
+ save_samples: bool = False,
+) -> None:
+ live_dir = run_dir / 'live_samples'
+ if save_samples:
+ live_dir.mkdir(parents=True, exist_ok=True)
+ if clean is not None:
+ cv2.imwrite(str(live_dir / 'latest_clean.jpg'), tensor_rgb_to_bgr_uint8(clean))
+ if watermarked is not None:
+ cv2.imwrite(str(live_dir / 'latest_watermarked.jpg'), tensor_rgb_to_bgr_uint8(watermarked))
+ if attacked is not None:
+ cv2.imwrite(str(live_dir / 'latest_attacked.jpg'), tensor_rgb_to_bgr_uint8(attacked))
+ target = run_dir / 'live_status.json'
+ tmp = run_dir / 'live_status.tmp'
+ tmp.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding='utf-8')
+ tmp.replace(target)
+
+
+def prepare_run_dir(config: dict[str, Any]) -> Path:
+ git = git_snapshot()
+ run_name = f"{utc_now_iso().replace(':', '').replace('+00:00', 'Z').replace('-', '')}_{git.get('shortCommit') or 'nogit'}"
+ run_dir = Path(config.get('artifacts_dir', 'artifacts/mlwm_v1')) / 'runs' / run_name
+ run_dir.mkdir(parents=True, exist_ok=True)
+ return run_dir
+
+
+def iter_enabled_stages(config: dict[str, Any], stage_filter: str | None = None):
+ stage_order = ['warmup', 'main', 'hard_negative', 'finalize']
+ stages = config.get('stages', {})
+ for name in stage_order:
+ stage_cfg = stages.get(name, {})
+ if stage_filter and name != stage_filter:
+ continue
+ if stage_cfg.get('enabled', False):
+ yield name, stage_cfg
+
+
+def train_main(args) -> dict[str, Any]:
+ torch, _, _ = require_torch()
+ random.seed(20260426)
+ np.random.seed(20260426)
+ torch.manual_seed(20260426)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(20260426)
+
+ config = resolve_cli_overrides(load_config(args.config), args)
+ run_dir = prepare_run_dir(config)
+ Path(run_dir / 'stdout.log').touch()
+ (run_dir / 'train_config_resolved.yaml').write_text(yaml.safe_dump(config, sort_keys=False), encoding='utf-8')
+
+ train_dataset = SyntheticPayloadImageDataset(config['train_dir'], int(config.get('image_size', 512)))
+ val_root = config.get('val_dir') or config['train_dir']
+ val_dataset = SyntheticPayloadImageDataset(val_root, int(config.get('image_size', 512)))
+ attack_cfg = attack_config_from_dict(config.get('attack', {}))
+
+ train_loader = torch.utils.data.DataLoader(
+ train_dataset,
+ batch_size=int(config.get('batch_size', 4)),
+ shuffle=True,
+ num_workers=int(config.get('num_workers', 8)),
+ pin_memory=torch.cuda.is_available(),
+ drop_last=True,
+ )
+ val_loader = torch.utils.data.DataLoader(
+ val_dataset,
+ batch_size=int(config.get('batch_size', 4)),
+ shuffle=False,
+ num_workers=max(1, int(config.get('num_workers', 8)) // 2),
+ pin_memory=torch.cuda.is_available(),
+ drop_last=False,
+ )
+
+ device = torch.device(config.get('device', 'cuda') if torch.cuda.is_available() else 'cpu')
+ model_cfg = config.get('model', {})
+ encoder, decoder = build_models(
+ payload_bits=int(config.get('payload_bits', 256)),
+ residual_scale=float(model_cfg.get('residual_scale', 8.0 / 255.0)),
+ )
+ encoder.to(device)
+ decoder.to(device)
+ checkpoint_path = config.get('checkpoint')
+ if checkpoint_path:
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
+ encoder.load_state_dict(checkpoint['encoder'])
+ decoder.load_state_dict(checkpoint['decoder'])
+ optimizer = torch.optim.AdamW(
+ list(encoder.parameters()) + list(decoder.parameters()),
+ lr=float(config.get('optimizer', {}).get('lr', 2e-4)),
+ weight_decay=float(config.get('optimizer', {}).get('weight_decay', 1e-5)),
+ )
+ scaler = torch.cuda.amp.GradScaler(enabled=bool(config.get('amp', True) and device.type == 'cuda'))
+ bce = torch.nn.BCEWithLogitsLoss()
+ mse = torch.nn.MSELoss()
+ grad_accum = int(config.get('grad_accum', 4))
+ loss_cfg = config.get('loss', {})
+ metrics_path = run_dir / 'metrics_epoch.csv'
+ live_start_time = time.time()
+ live_status_interval = int(config.get('dashboard', {}).get('status_interval_batches', 1))
+ live_sample_interval = int(config.get('dashboard', {}).get('sample_interval_batches', 10))
+ best_score = -1.0
+ best_epoch = -1
+ best_ckpt_path = run_dir / 'best.ckpt'
+ global_epoch = 0
+
+ with metrics_path.open('w', encoding='utf-8', newline='') as f:
+ writer = csv.writer(f)
+ writer.writerow(['epoch', 'stage', 'train_loss', 'val_payload_acc', 'val_exact_match', 'val_decode_success', 'val_confidence'])
+
+ for stage_name, stage_cfg in iter_enabled_stages(config, args.stage):
+ freeze_encoder = bool(stage_cfg.get('freeze_encoder', False))
+ freeze_decoder = bool(stage_cfg.get('freeze_decoder', False))
+ use_fixed_residual = bool(stage_cfg.get('use_fixed_residual', freeze_encoder))
+ stage_loss_cfg = deep_update(loss_cfg, stage_cfg.get('loss', {}))
+ for parameter in encoder.parameters():
+ parameter.requires_grad = not freeze_encoder
+ for parameter in decoder.parameters():
+ parameter.requires_grad = not freeze_decoder
+ stage_epochs = int(stage_cfg.get('epochs', 1))
+ for stage_epoch_index in range(stage_epochs):
+ global_epoch += 1
+ encoder.train(not freeze_encoder)
+ decoder.train(not freeze_decoder)
+ total_loss = 0.0
+ optimizer.zero_grad(set_to_none=True)
+ for batch_index, batch in enumerate(train_loader):
+ clean = batch['image'].to(device)
+ bits = batch['bits'].to(device)
+
+ with torch.cuda.amp.autocast(enabled=scaler.is_enabled()):
+ if use_fixed_residual:
+ residual = fixed_residual_batch(
+ torch,
+ bits,
+ clean.shape[-2],
+ clean.shape[-1],
+ float(stage_cfg.get('fixed_residual_scale', 0.02)),
+ device,
+ )
+ else:
+ residual = encoder(clean, bits)
+
+ watermarked = torch.clamp(clean + residual, 0.0, 1.0)
+ attacked = attack_batch(torch, watermarked, attack_cfg, str(stage_cfg.get('attack_strength', 'medium'))).to(device)
+ logits, confidence = decoder(attacked)
+ payload_loss = bce(logits, bits)
+ image_loss = mse(watermarked, clean) + 0.3 * (1.0 - torch_ssim(torch, watermarked, clean))
+ residual_l2 = residual.pow(2).mean()
+ residual_tv = total_variation(torch, residual)
+ teacher_loss = torch.zeros((), device=device)
+ teacher_weight = float(stage_cfg.get('encoder_teacher_weight', 0.0))
+ if teacher_weight > 0.0 and not freeze_encoder:
+ teacher_residual = fixed_residual_batch(
+ torch,
+ bits,
+ clean.shape[-2],
+ clean.shape[-1],
+ float(stage_cfg.get('encoder_teacher_scale', stage_cfg.get('fixed_residual_scale', 0.02))),
+ device,
+ )
+ teacher_loss = mse(residual, teacher_residual)
+ bit_correctness = ((logits.detach() >= 0) == (bits >= 0.5)).float().mean(dim=1, keepdim=True)
+ confidence_loss = mse(torch.sigmoid(confidence), bit_correctness)
+ loss = (
+ float(stage_loss_cfg.get('payload_weight', 5.0)) * payload_loss +
+ float(stage_loss_cfg.get('image_weight', 1.0)) * image_loss +
+ float(stage_loss_cfg.get('residual_weight', 0.1)) * residual_l2 +
+ float(stage_loss_cfg.get('tv_weight', 0.05)) * residual_tv +
+ teacher_weight * teacher_loss +
+ 0.1 * confidence_loss
+ ) / grad_accum
+
+ scaler.scale(loss).backward()
+ if (batch_index + 1) % grad_accum == 0:
+ scaler.step(optimizer)
+ scaler.update()
+ optimizer.zero_grad(set_to_none=True)
+ last_loss = float(loss.item()) * grad_accum
+ total_loss += last_loss
+ if (batch_index + 1) % max(1, live_status_interval) == 0:
+ batches_per_epoch = max(len(train_loader), 1)
+ completed_batches = batch_index + 1
+ samples_seen = ((global_epoch - 1) * batches_per_epoch + completed_batches) * int(config.get('batch_size', 4))
+ train_bit_acc = float(((logits.detach() >= 0) == (bits >= 0.5)).float().mean().item())
+ write_live_status(
+ run_dir,
+ status={
+ 'phase': 'train',
+ 'updatedAt': utc_now_iso(),
+ 'stage': stage_name,
+ 'stageEpoch': stage_epoch_index + 1,
+ 'stageEpochs': stage_epochs,
+ 'epoch': global_epoch,
+ 'batch': completed_batches,
+ 'batchesPerEpoch': batches_per_epoch,
+ 'batchPercent': completed_batches / batches_per_epoch,
+ 'samplesSeen': samples_seen,
+ 'lastLoss': last_loss,
+ 'runningLoss': total_loss / completed_batches,
+ 'trainBitAcc': train_bit_acc,
+ 'elapsedSeconds': time.time() - live_start_time,
+ 'sampleImages': {
+ 'clean': 'live_samples/latest_clean.jpg',
+ 'watermarked': 'live_samples/latest_watermarked.jpg',
+ 'attacked': 'live_samples/latest_attacked.jpg',
+ },
+ },
+ clean=clean,
+ watermarked=watermarked,
+ attacked=attacked,
+ save_samples=(batch_index == 0) or ((batch_index + 1) % max(1, live_sample_interval) == 0),
+ )
+
+ encoder.eval()
+ decoder.eval()
+ val_payload_acc = 0.0
+ val_exact = 0.0
+ val_decode_success = 0.0
+ val_conf = 0.0
+ val_batches = 0
+ write_live_status(
+ run_dir,
+ status={
+ 'phase': 'validation',
+ 'updatedAt': utc_now_iso(),
+ 'stage': stage_name,
+ 'stageEpoch': stage_epoch_index + 1,
+ 'stageEpochs': stage_epochs,
+ 'epoch': global_epoch,
+ 'batch': len(train_loader),
+ 'batchesPerEpoch': max(len(train_loader), 1),
+ 'batchPercent': 1.0,
+ 'samplesSeen': global_epoch * max(len(train_loader), 1) * int(config.get('batch_size', 4)),
+ 'lastLoss': total_loss / max(len(train_loader), 1),
+ 'runningLoss': total_loss / max(len(train_loader), 1),
+ 'elapsedSeconds': time.time() - live_start_time,
+ 'sampleImages': {
+ 'clean': 'live_samples/latest_clean.jpg',
+ 'watermarked': 'live_samples/latest_watermarked.jpg',
+ 'attacked': 'live_samples/latest_attacked.jpg',
+ },
+ },
+ )
+ with torch.no_grad():
+ for batch in val_loader:
+ clean = batch['image'].to(device)
+ bits = batch['bits'].to(device)
+ residual = fixed_residual_batch(
+ torch,
+ bits,
+ clean.shape[-2],
+ clean.shape[-1],
+ float(stage_cfg.get('fixed_residual_scale', 0.02)),
+ device,
+ ) if use_fixed_residual else encoder(clean, bits)
+ watermarked = torch.clamp(clean + residual, 0.0, 1.0)
+ attacked = attack_batch(torch, watermarked, attack_cfg, str(stage_cfg.get('attack_strength', 'medium'))).to(device)
+ logits, confidence = decoder(attacked)
+ logits_np = logits.detach().cpu().numpy()
+ val_payload_acc += bit_accuracy(logits_np, bits.detach().cpu().numpy())
+ val_exact += exact_match_rate(logits_np, bits.detach().cpu().numpy())
+ val_decode_success += decode_success_rate(logits_np, list(batch['text']))
+ val_conf += float(torch.sigmoid(confidence).mean().item())
+ val_batches += 1
+
+ val_payload_acc /= max(val_batches, 1)
+ val_exact /= max(val_batches, 1)
+ val_decode_success /= max(val_batches, 1)
+ val_conf /= max(val_batches, 1)
+ writer.writerow([global_epoch, stage_name, total_loss / max(len(train_loader), 1), val_payload_acc, val_exact, val_decode_success, val_conf])
+ f.flush()
+ write_live_status(
+ run_dir,
+ status={
+ 'phase': 'epoch_complete',
+ 'updatedAt': utc_now_iso(),
+ 'stage': stage_name,
+ 'stageEpoch': stage_epoch_index + 1,
+ 'stageEpochs': stage_epochs,
+ 'epoch': global_epoch,
+ 'batch': len(train_loader),
+ 'batchesPerEpoch': max(len(train_loader), 1),
+ 'batchPercent': 1.0,
+ 'samplesSeen': global_epoch * max(len(train_loader), 1) * int(config.get('batch_size', 4)),
+ 'lastLoss': total_loss / max(len(train_loader), 1),
+ 'runningLoss': total_loss / max(len(train_loader), 1),
+ 'elapsedSeconds': time.time() - live_start_time,
+ 'valPayloadAcc': val_payload_acc,
+ 'valExactMatch': val_exact,
+ 'valDecodeSuccess': val_decode_success,
+ 'valConfidence': val_conf,
+ 'sampleImages': {
+ 'clean': 'live_samples/latest_clean.jpg',
+ 'watermarked': 'live_samples/latest_watermarked.jpg',
+ 'attacked': 'live_samples/latest_attacked.jpg',
+ },
+ },
+ )
+
+ score = val_payload_acc + val_exact + val_decode_success
+ if score > best_score:
+ best_score = score
+ best_epoch = global_epoch
+ save_checkpoint(
+ best_ckpt_path,
+ {
+ 'encoder': encoder.state_dict(),
+ 'decoder': decoder.state_dict(),
+ 'config': config,
+ 'datasetManifestHash': train_dataset.manifest_hash,
+ 'bestMetric': score,
+ 'bestEpoch': best_epoch,
+ 'benchmarkSummary': {
+ 'valPayloadAcc': val_payload_acc,
+ 'valExactMatch': val_exact,
+ 'valDecodeSuccess': val_decode_success,
+ },
+ },
+ torch,
+ )
+
+ manifest = build_run_manifest(
+ repo_root=os.getcwd(),
+ config=config,
+ train_dir=config['train_dir'],
+ val_dir=val_root,
+ dataset_hash=train_dataset.manifest_hash,
+ output_dir=str(run_dir),
+ promoted=False,
+ extra={
+ 'startTime': utc_now_iso(),
+ 'endTime': utc_now_iso(),
+ 'bestEpoch': best_epoch,
+ 'bestCheckpoint': str(best_ckpt_path),
+ 'bestScore': best_score,
+ 'initCheckpoint': str(checkpoint_path) if checkpoint_path else None,
+ },
+ )
+ write_json(run_dir / 'run_manifest.json', manifest)
+ return {
+ 'runDir': str(run_dir),
+ 'bestCheckpoint': str(best_ckpt_path),
+ 'bestEpoch': best_epoch,
+ 'bestScore': best_score,
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description='Train MLWM v1')
+ parser.add_argument('--config', required=True)
+ parser.add_argument('--train-dir')
+ parser.add_argument('--val-dir')
+ parser.add_argument('--workdir')
+ parser.add_argument('--image-size', type=int)
+ parser.add_argument('--batch-size', type=int)
+ parser.add_argument('--grad-accum', type=int)
+ parser.add_argument('--epochs', type=int)
+ parser.add_argument('--amp', action='store_true')
+ parser.add_argument('--device')
+ parser.add_argument('--checkpoint')
+ parser.add_argument('--stage', choices=['warmup', 'main', 'hard_negative', 'finalize'])
+ args = parser.parse_args()
+ result = train_main(args)
+ print(yaml.safe_dump(result, sort_keys=False))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/blind_watermark/requirements-ml.txt b/blind_watermark/requirements-ml.txt
new file mode 100644
index 0000000..64424b9
--- /dev/null
+++ b/blind_watermark/requirements-ml.txt
@@ -0,0 +1,12 @@
+numpy>=1.26.0
+opencv-python>=4.9.0
+scipy>=1.11.0
+reedsolo>=1.7.0
+PyYAML>=6.0.1
+torch>=2.4.0
+torchvision>=0.19.0
+kornia>=0.7.3
+onnx>=1.17.0
+onnxscript>=0.5.0
+onnxruntime>=1.20.0
+tensorboard>=2.18.0
diff --git a/blind_watermark/requirements-onnx.txt b/blind_watermark/requirements-onnx.txt
new file mode 100644
index 0000000..a53b3f9
--- /dev/null
+++ b/blind_watermark/requirements-onnx.txt
@@ -0,0 +1,5 @@
+numpy>=1.26.0
+opencv-python>=4.9.0
+scipy>=1.11.0
+reedsolo>=1.7.0
+onnxruntime>=1.20.0
diff --git a/blind_watermark/requirements.txt b/blind_watermark/requirements.txt
index c7ab02a..c89a722 100644
--- a/blind_watermark/requirements.txt
+++ b/blind_watermark/requirements.txt
@@ -3,3 +3,4 @@ opencv-python
scipy>=1.7.0
reedsolo>=0.6.0
setuptools
+onnxruntime>=1.20.0
diff --git a/blind_watermark/rwm_engine.py b/blind_watermark/rwm_engine.py
index 780110e..343a9c7 100644
--- a/blind_watermark/rwm_engine.py
+++ b/blind_watermark/rwm_engine.py
@@ -30,6 +30,7 @@
Dependencies: numpy, opencv-python, scipy, reedsolo
"""
+import os
import numpy as np
import cv2
from scipy.fft import fft2, ifft2, fftshift, ifftshift
@@ -81,6 +82,20 @@
# v2 compat constants
_V2_RS_NSYM = 20
_V2_REDUNDANCY = 3
+RWM_VERSION = '3.1.0'
+NEURAL_MAX_TEXT_BYTES = 16
+NEURAL_PROFILES = {
+ 'balanced': {
+ 'residual_strength': 1.0,
+ 'template_strength': 0.008,
+ 'template_peaks': 96,
+ },
+ 'aggressive': {
+ 'residual_strength': 1.35,
+ 'template_strength': 0.012,
+ 'template_peaks': 128,
+ },
+}
# ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -340,7 +355,7 @@ def _count_available_blocks(h, w, margin_ratio):
# ─── Public API ───────────────────────────────────────────────────────────────
-def embed_watermark(img, text, password=1, quality='balanced', self_check=True):
+def embed_watermark_legacy(img, text, password=1, quality='balanced', self_check=True):
if quality not in QUALITY:
raise ValueError(f'quality must be one of {list(QUALITY.keys())}')
cfg = QUALITY[quality]
@@ -403,7 +418,7 @@ def embed_watermark(img, text, password=1, quality='balanced', self_check=True):
# Self-check: verify watermark can be extracted from the output
if self_check:
try:
- extracted = extract_watermark(out, password, quality)
+ extracted = extract_watermark_legacy(out, password, quality)
if extracted != text:
raise ValueError('self-check mismatch')
except Exception:
@@ -411,13 +426,13 @@ def embed_watermark(img, text, password=1, quality='balanced', self_check=True):
current_idx = quality_order.index(quality)
if current_idx < len(quality_order) - 1:
upgraded = quality_order[current_idx + 1]
- return embed_watermark(img if alpha is None else cv2.merge([img, alpha]),
- text, password, upgraded, self_check=False)
+ return embed_watermark_legacy(img if alpha is None else cv2.merge([img, alpha]),
+ text, password, upgraded, self_check=False)
return out
-def extract_watermark(img, password=1, quality='balanced'):
+def extract_watermark_legacy(img, password=1, quality='balanced'):
if quality not in QUALITY:
quality = 'balanced'
@@ -499,6 +514,241 @@ def _try_all_presets(gray_ch, preset_order=None):
raise ValueError('No valid watermark found in image')
+def _resolve_neural_profile(quality):
+ if quality == 'robust':
+ return 'aggressive'
+ return 'balanced'
+
+
+def _center_square(image):
+ h, w = image.shape[:2]
+ edge = min(h, w)
+ y0 = max(0, (h - edge) // 2)
+ x0 = max(0, (w - edge) // 2)
+ return image[y0:y0 + edge, x0:x0 + edge]
+
+
+def _rectify_neural_image(img, password):
+ gray_orig = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY).astype(np.float64)
+ h, w = gray_orig.shape
+ sd = _seed(password)
+ best = {'angle': 0.0, 'scale': 1.0, 'confidence': 0.0, 'peaks': 64}
+ for num_peaks in [128, 96, 64]:
+ angle, scale, conf = _detect_transform(gray_orig, sd, num_peaks)
+ if conf > best['confidence']:
+ best = {'angle': float(angle), 'scale': float(scale), 'confidence': float(conf), 'peaks': int(num_peaks)}
+ corrected = img[:, :, :3]
+ if best['confidence'] > 4.0 and (abs(best['angle']) > 0.2 or abs(best['scale'] - 1.0) > 0.01):
+ matrix = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), -best['angle'], 1.0 / max(best['scale'], 1e-3))
+ corrected = cv2.warpAffine(corrected, matrix, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
+ return corrected, best
+
+
+def _build_neural_views(corrected):
+ rgb = cv2.cvtColor(corrected[:, :, :3], cv2.COLOR_BGR2RGB)
+ center_crop = _center_square(rgb)
+ center_crop = cv2.resize(center_crop, (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_CUBIC)
+ padded = cv2.copyMakeBorder(rgb, 24, 24, 24, 24, borderType=cv2.BORDER_REFLECT)
+ padded = cv2.resize(padded, (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_CUBIC)
+ return [rgb, center_crop, padded]
+
+
+def _legacy_embed_response(image, quality, requested_engine, fallback_reason=None):
+ diagnostics = {
+ 'quality': quality,
+ 'fallbackReason': fallback_reason,
+ }
+ return {
+ 'ok': True,
+ 'image': image,
+ 'engine_used': 'legacy',
+ 'fallback_used': requested_engine != 'legacy',
+ 'quality_used': quality,
+ 'confidence': 1.0,
+ 'diagnostics': diagnostics,
+ }
+
+
+def _legacy_extract_response(text, quality, requested_engine, fallback_reason=None):
+ diagnostics = {
+ 'quality': quality,
+ 'fallbackReason': fallback_reason,
+ }
+ return {
+ 'ok': True,
+ 'wm': text,
+ 'engine_used': 'legacy',
+ 'fallback_used': requested_engine != 'legacy',
+ 'confidence': 1.0,
+ 'diagnostics': diagnostics,
+ }
+
+
+def _neural_embed_impl(img, text, password=1, quality='balanced', models_dir=None, self_check=True):
+ if len(text.encode('utf-8')) > NEURAL_MAX_TEXT_BYTES:
+ raise ValueError(f'Neural watermark supports up to {NEURAL_MAX_TEXT_BYTES} UTF-8 bytes')
+ try:
+ from blind_watermark.mlwm.codec import encode_text_payload
+ from blind_watermark.mlwm.infer import NeuralRuntimeUnavailable, neural_encode_residual, apply_neural_residual
+ except ImportError:
+ from mlwm.codec import encode_text_payload
+ from mlwm.infer import NeuralRuntimeUnavailable, neural_encode_residual, apply_neural_residual
+
+ profile_name = _resolve_neural_profile(quality)
+ profile = NEURAL_PROFILES[profile_name]
+ alpha = img[:, :, 3].copy() if img.ndim == 3 and img.shape[2] == 4 else None
+ base = img[:, :, :3] if alpha is not None else img.copy()
+ payload = encode_text_payload(text)
+ rgb = cv2.cvtColor(base, cv2.COLOR_BGR2RGB)
+ try:
+ encoded = neural_encode_residual(rgb, payload.bits, models_dir=models_dir, use_cuda=False)
+ except NeuralRuntimeUnavailable:
+ raise
+ rgb_watermarked = apply_neural_residual(rgb, encoded['residual'], strength=profile['residual_strength'])
+ out = cv2.cvtColor(rgb_watermarked, cv2.COLOR_RGB2BGR).astype(np.float64)
+
+ gray = cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_BGR2GRAY).astype(np.float64)
+ gray_sync = _embed_template(gray, _seed(password), profile['template_strength'], profile['template_peaks'])
+ total_diff = gray_sync - gray
+ for c in range(3):
+ out[:, :, c] += total_diff
+ out = np.clip(np.round(out), 0, 255).astype(np.uint8)
+
+ if alpha is not None:
+ out = cv2.merge([out, alpha])
+
+ diagnostics = {
+ 'profile': profile_name,
+ 'payloadBytes': len(payload.text_bytes),
+ 'modelVersion': encoded.get('modelVersion'),
+ 'modelsDir': models_dir,
+ }
+
+ if self_check:
+ extracted = _neural_extract_impl(out, password=password, quality=quality, models_dir=models_dir)
+ if not extracted.get('ok') or extracted.get('wm') != text:
+ raise ValueError('Neural self-check mismatch')
+
+ return {
+ 'ok': True,
+ 'image': out,
+ 'engine_used': 'neural',
+ 'fallback_used': False,
+ 'quality_used': profile_name,
+ 'confidence': 1.0,
+ 'diagnostics': diagnostics,
+ }
+
+
+def _neural_extract_impl(img, password=1, quality='balanced', models_dir=None):
+ try:
+ from blind_watermark.mlwm.infer import NeuralRuntimeUnavailable, neural_decode_views
+ except ImportError:
+ from mlwm.infer import NeuralRuntimeUnavailable, neural_decode_views
+
+ corrected, geo = _rectify_neural_image(img[:, :, :3] if img.ndim == 3 else img, password)
+ views = _build_neural_views(corrected)
+ try:
+ decoded = neural_decode_views(views, models_dir=models_dir, use_cuda=False)
+ except NeuralRuntimeUnavailable:
+ raise
+
+ confidence = float(decoded.get('confidence', decoded.get('bitConfidence', 0.0)))
+ return {
+ 'ok': True,
+ 'wm': decoded['text'],
+ 'engine_used': 'neural',
+ 'fallback_used': False,
+ 'confidence': confidence,
+ 'diagnostics': {
+ 'profile': _resolve_neural_profile(quality),
+ 'bitConfidence': float(decoded.get('bitConfidence', 0.0)),
+ 'decodeStrategy': decoded.get('strategy'),
+ 'geometricCorrection': geo,
+ 'attemptIndex': decoded.get('attemptIndex'),
+ 'attempts': decoded.get('attempts'),
+ 'modelVersion': decoded.get('manifest', {}).get('modelVersion'),
+ 'modelsDir': models_dir,
+ },
+ }
+
+
+def embed_watermark(img, text, password=1, quality='balanced', engine='auto', models_dir=None, self_check=True):
+ requested_engine = engine if engine in ('auto', 'legacy', 'neural') else 'auto'
+ text_bytes = text.encode('utf-8')
+ if requested_engine == 'legacy':
+ legacy = embed_watermark_legacy(img, text, password=password, quality=quality, self_check=self_check)
+ return _legacy_embed_response(legacy, quality, requested_engine)
+
+ neural_allowed = len(text_bytes) <= NEURAL_MAX_TEXT_BYTES
+ large_enough = min(img.shape[:2]) >= 512 if hasattr(img, 'shape') else False
+
+ if requested_engine == 'neural' and not neural_allowed:
+ return {
+ 'ok': False,
+ 'error': f'Neural watermark supports up to {NEURAL_MAX_TEXT_BYTES} UTF-8 bytes',
+ 'engine_used': 'neural',
+ 'fallback_used': False,
+ }
+
+ if requested_engine == 'auto' and (not neural_allowed or not large_enough):
+ reason = 'payload-too-long' if not neural_allowed else 'image-too-small-for-neural-auto'
+ legacy = embed_watermark_legacy(img, text, password=password, quality=quality, self_check=self_check)
+ return _legacy_embed_response(legacy, quality, requested_engine, reason)
+
+ try:
+ return _neural_embed_impl(
+ img,
+ text,
+ password=password,
+ quality=quality,
+ models_dir=models_dir,
+ self_check=self_check,
+ )
+ except Exception as exc:
+ if requested_engine == 'neural':
+ return {
+ 'ok': False,
+ 'error': str(exc),
+ 'engine_used': 'neural',
+ 'fallback_used': False,
+ }
+ legacy = embed_watermark_legacy(img, text, password=password, quality=quality, self_check=self_check)
+ return _legacy_embed_response(legacy, quality, requested_engine, f'neural-failed:{exc}')
+
+
+def extract_watermark(img, password=1, quality='balanced', engine='auto', models_dir=None):
+ requested_engine = engine if engine in ('auto', 'legacy', 'neural') else 'auto'
+ if requested_engine == 'legacy':
+ text = extract_watermark_legacy(img, password=password, quality=quality)
+ return _legacy_extract_response(text, quality, requested_engine)
+
+ try:
+ return _neural_extract_impl(img, password=password, quality=quality, models_dir=models_dir)
+ except Exception as exc:
+ if requested_engine == 'neural':
+ return {
+ 'ok': False,
+ 'error': str(exc),
+ 'engine_used': 'neural',
+ 'fallback_used': False,
+ }
+ try:
+ text = extract_watermark_legacy(img, password=password, quality=quality)
+ return _legacy_extract_response(text, quality, requested_engine, f'neural-failed:{exc}')
+ except Exception as legacy_exc:
+ return {
+ 'ok': False,
+ 'error': str(legacy_exc),
+ 'engine_used': 'legacy',
+ 'fallback_used': True,
+ 'diagnostics': {
+ 'fallbackReason': f'neural-failed:{exc}',
+ 'legacyError': str(legacy_exc),
+ },
+ }
+
+
def _try_extract_single_with_seed(gray, sd, quality_name, rs_nsym, redundancy,
margin_ratio, num_peaks):
"""Like _try_extract_single but uses sd directly (without +1 offset for DCT seed)."""
@@ -526,11 +776,26 @@ def _try_extract_single_with_seed(gray, sd, quality_name, rs_nsym, redundancy,
def check_dependencies():
- res = {'ok': True, 'version': '3.0.0', 'missing': []}
+ res = {'ok': True, 'version': RWM_VERSION, 'missing': []}
for m in ['numpy', 'cv2', 'scipy', 'reedsolo']:
try:
__import__(m)
except ImportError:
res['ok'] = False
res['missing'].append(m)
+ try:
+ try:
+ from blind_watermark.mlwm.infer import probe_runtime
+ except ImportError:
+ from mlwm.infer import probe_runtime
+ status = probe_runtime(os.environ.get('LUMINCRYPT_MLWM_MODELS_DIR'))
+ res['neuralRuntimeAvailable'] = bool(status.runtime_available)
+ res['neuralModelsAvailable'] = bool(status.models_available)
+ res['neuralReady'] = bool(status.ready)
+ res['neuralModelVersion'] = status.manifest.get('modelVersion')
+ except Exception:
+ res['neuralRuntimeAvailable'] = False
+ res['neuralModelsAvailable'] = False
+ res['neuralReady'] = False
+ res['neuralModelVersion'] = None
return res
diff --git a/blind_watermark/tests/__init__.py b/blind_watermark/tests/__init__.py
new file mode 100644
index 0000000..11432d6
--- /dev/null
+++ b/blind_watermark/tests/__init__.py
@@ -0,0 +1 @@
+"""Unit tests for MLWM helpers."""
diff --git a/blind_watermark/tests/test_mlwm_attacks.py b/blind_watermark/tests/test_mlwm_attacks.py
new file mode 100644
index 0000000..75a961c
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_attacks.py
@@ -0,0 +1,43 @@
+import random
+import unittest
+
+import numpy as np
+
+from blind_watermark.mlwm.attacks import AttackConfig, apply_random_attack_chain
+
+try:
+ import torch
+except ImportError: # pragma: no cover
+ torch = None
+
+
+class AttackTests(unittest.TestCase):
+ def test_clean_strength_is_identity_copy(self):
+ image = np.random.default_rng(1).integers(0, 256, (64, 64, 3), dtype=np.uint8)
+ out = apply_random_attack_chain(image, config=AttackConfig(), rng=random.Random(1), strength='clean')
+ self.assertTrue(np.array_equal(out, image))
+ self.assertIsNot(out, image)
+
+ def test_enabled_ops_limits_attack_choices(self):
+ image = np.full((64, 64, 3), 128, dtype=np.uint8)
+ cfg = AttackConfig(enabled_ops=('gaussian_noise',), ops_per_sample_min=1, ops_per_sample_max=1, gaussian_noise_std=(0.1, 0.1))
+ out = apply_random_attack_chain(image, config=cfg, rng=random.Random(1), strength='medium')
+ self.assertEqual(out.shape, image.shape)
+ self.assertFalse(np.array_equal(out, image))
+
+ @unittest.skipIf(torch is None, 'PyTorch is not installed')
+ def test_attack_batch_uses_straight_through_gradient(self):
+ try:
+ from blind_watermark.mlwm.train import attack_batch
+ except ImportError as exc:
+ self.skipTest(f'ML training dependencies are not installed: {exc}')
+
+ image = torch.rand(1, 3, 40, 40, requires_grad=True)
+ out = attack_batch(torch, image, AttackConfig(ops_per_sample_min=1, ops_per_sample_max=1), 'medium')
+ self.assertTrue(out.requires_grad)
+ out.sum().backward()
+ self.assertIsNotNone(image.grad)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/blind_watermark/tests/test_mlwm_codec.py b/blind_watermark/tests/test_mlwm_codec.py
new file mode 100644
index 0000000..443855d
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_codec.py
@@ -0,0 +1,55 @@
+import unittest
+
+import numpy as np
+
+from blind_watermark.mlwm.codec import (
+ ENCODED_BYTES,
+ MAX_TEXT_BYTES,
+ bits_to_bytes,
+ bytes_to_bits,
+ decode_payload_bits,
+ encode_frame,
+ encode_text_payload,
+ unwhiten_payload_bits,
+)
+from blind_watermark.mlwm.metrics import decode_success_rate
+
+
+class CodecTests(unittest.TestCase):
+ def test_roundtrip_short_text(self):
+ envelope = encode_text_payload('TRACE-42')
+ decoded = decode_payload_bits(envelope.bits)
+ self.assertEqual(decoded['text'], 'TRACE-42')
+ self.assertEqual(len(envelope.encoded), ENCODED_BYTES)
+
+ def test_reject_long_text(self):
+ with self.assertRaises(ValueError):
+ encode_text_payload('X' * (MAX_TEXT_BYTES + 1))
+
+ def test_bits_to_bytes_roundtrip(self):
+ envelope = encode_text_payload('HELLO')
+ rebuilt = bits_to_bytes(unwhiten_payload_bits(envelope.bits))
+ self.assertEqual(rebuilt, envelope.encoded)
+ self.assertTrue(np.array_equal(envelope.bits, envelope.bits.copy()))
+
+ def test_payload_bits_are_whitened(self):
+ samples = [encode_text_payload(f'TRACE-{i:04d}').bits for i in range(1000)]
+ mean_ones = float(np.stack(samples).mean())
+ self.assertGreater(mean_ones, 0.45)
+ self.assertLess(mean_ones, 0.55)
+
+ def test_whitening_is_reversible(self):
+ envelope = encode_text_payload('ROUNDTRIP')
+ raw_bits = unwhiten_payload_bits(envelope.bits)
+ self.assertEqual(bits_to_bytes(raw_bits), envelope.encoded)
+ self.assertTrue(np.array_equal(bytes_to_bits(encode_frame(envelope.frame)), raw_bits))
+
+ def test_decode_success_rate(self):
+ envelope = encode_text_payload('OK-42')
+ logits = (envelope.bits * 2.0 - 1.0) * 8.0
+ self.assertEqual(decode_success_rate(logits.reshape(1, -1), ['OK-42']), 1.0)
+ self.assertEqual(decode_success_rate(logits.reshape(1, -1), ['NOPE']), 0.0)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/blind_watermark/tests/test_mlwm_download_unsplash_lite.py b/blind_watermark/tests/test_mlwm_download_unsplash_lite.py
new file mode 100644
index 0000000..51e8171
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_download_unsplash_lite.py
@@ -0,0 +1,16 @@
+import unittest
+
+from blind_watermark.mlwm.download_unsplash_lite import image_url
+
+
+class DownloadUnsplashLiteTests(unittest.TestCase):
+ def test_image_url_adds_resize_parameters(self):
+ url = image_url('https://images.unsplash.com/photo.jpg?ixid=abc', width=1024, quality=85)
+ self.assertIn('ixid=abc', url)
+ self.assertIn('w=1024', url)
+ self.assertIn('q=85', url)
+ self.assertIn('fit=max', url)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/blind_watermark/tests/test_mlwm_models.py b/blind_watermark/tests/test_mlwm_models.py
new file mode 100644
index 0000000..9ecafeb
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_models.py
@@ -0,0 +1,35 @@
+import unittest
+
+from blind_watermark.mlwm.codec import PAYLOAD_BITS
+
+try:
+ import torch
+
+ from blind_watermark.mlwm.models import build_models
+except ImportError: # pragma: no cover
+ torch = None
+ build_models = None
+
+
+@unittest.skipIf(torch is None, 'PyTorch is not installed')
+class ModelTests(unittest.TestCase):
+ def test_decoder_outputs_payload_grid(self):
+ _, decoder = build_models(payload_bits=PAYLOAD_BITS)
+ image = torch.rand(2, 3, 128, 128)
+ logits, confidence = decoder(image)
+ self.assertEqual(tuple(logits.shape), (2, PAYLOAD_BITS))
+ self.assertEqual(tuple(confidence.shape), (2, 1))
+
+ def test_encoder_decoder_roundtrip_shapes(self):
+ encoder, decoder = build_models(payload_bits=PAYLOAD_BITS)
+ image = torch.rand(2, 3, 128, 128)
+ bits = torch.randint(0, 2, (2, PAYLOAD_BITS)).float()
+ residual = encoder(image, bits)
+ logits, confidence = decoder(torch.clamp(image + residual, 0.0, 1.0))
+ self.assertEqual(tuple(residual.shape), tuple(image.shape))
+ self.assertEqual(tuple(logits.shape), (2, PAYLOAD_BITS))
+ self.assertEqual(tuple(confidence.shape), (2, 1))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/blind_watermark/tests/test_mlwm_prepare_dataset.py b/blind_watermark/tests/test_mlwm_prepare_dataset.py
new file mode 100644
index 0000000..9ba4592
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_prepare_dataset.py
@@ -0,0 +1,48 @@
+import json
+import shutil
+import unittest
+from pathlib import Path
+
+import cv2
+import numpy as np
+
+from blind_watermark.mlwm.prepare_dataset import prepare_dataset
+
+
+class PrepareDatasetTests(unittest.TestCase):
+ def test_prepare_dataset_filters_dedupes_and_writes_manifest(self):
+ base = Path('tmp/mlwm-prepare-tests').resolve()
+ if base.exists():
+ shutil.rmtree(base)
+ source = base / 'source'
+ out = base / 'data'
+ source.mkdir(parents=True)
+
+ large = np.full((640, 640, 3), 127, dtype=np.uint8)
+ small = np.full((128, 128, 3), 64, dtype=np.uint8)
+ cv2.imwrite(str(source / 'large_a.png'), large)
+ cv2.imwrite(str(source / 'large_duplicate.png'), large)
+ cv2.imwrite(str(source / 'small.png'), small)
+
+ manifest = prepare_dataset(
+ [str(source)],
+ str(out),
+ val_ratio=0.25,
+ min_size=512,
+ seed=1,
+ copy_mode='copy',
+ clean=True,
+ )
+
+ self.assertEqual(manifest['counts']['total'], 1)
+ self.assertEqual(manifest['counts']['train'], 1)
+ self.assertEqual(manifest['counts']['val'], 0)
+ self.assertEqual(manifest['rejected'], 1)
+ self.assertTrue((out / 'dataset_manifest.json').exists())
+
+ loaded = json.loads((out / 'dataset_manifest.json').read_text(encoding='utf-8'))
+ self.assertEqual(loaded['counts']['total'], 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/blind_watermark/tests/test_mlwm_traceability.py b/blind_watermark/tests/test_mlwm_traceability.py
new file mode 100644
index 0000000..54c41f7
--- /dev/null
+++ b/blind_watermark/tests/test_mlwm_traceability.py
@@ -0,0 +1,24 @@
+import json
+import unittest
+from pathlib import Path
+
+from blind_watermark.mlwm.traceability import config_hash, write_json
+
+
+class TraceabilityTests(unittest.TestCase):
+ def test_config_hash_is_stable(self):
+ cfg = {'b': 2, 'a': 1}
+ self.assertEqual(config_hash(cfg), config_hash({'a': 1, 'b': 2}))
+
+ def test_write_json_creates_parent(self):
+ base = Path('tmp/mlwm-tests').resolve()
+ target = base / 'nested' / 'payload.json'
+ if target.exists():
+ target.unlink()
+ write_json(target, {'ok': True})
+ self.assertTrue(target.exists())
+ self.assertEqual(json.loads(target.read_text(encoding='utf-8'))['ok'], True)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/build_bwm.spec b/build_bwm.spec
index 4ec22a5..310fd28 100644
--- a/build_bwm.spec
+++ b/build_bwm.spec
@@ -18,14 +18,21 @@ from PyInstaller.utils.hooks import collect_all
# rwm_engine is in pathex, no extra package to collect
bwm_datas, bwm_binaries, bwm_hidden = [], [], []
+try:
+ ort_datas, ort_binaries, ort_hidden = collect_all('onnxruntime')
+except Exception:
+ ort_datas, ort_binaries, ort_hidden = [], [], []
a = Analysis(
['blind_watermark/bwm_helper.py'],
# Add the directory so rwm_engine is found at import time
pathex=['blind_watermark'],
- binaries=bwm_binaries,
- datas=bwm_datas,
+ binaries=bwm_binaries + ort_binaries,
+ datas=bwm_datas + ort_datas,
hiddenimports=bwm_hidden + [
+ 'mlwm',
+ 'mlwm.codec',
+ 'mlwm.infer',
# OpenCV
'cv2',
# NumPy / SciPy internals often missed by static analysis
@@ -37,7 +44,7 @@ a = Analysis(
'scipy.ndimage',
# Reed-Solomon error correction
'reedsolo',
- ],
+ ] + ort_hidden,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
diff --git a/configs/mlwm/base.yaml b/configs/mlwm/base.yaml
new file mode 100644
index 0000000..3ad140a
--- /dev/null
+++ b/configs/mlwm/base.yaml
@@ -0,0 +1,59 @@
+seed: 20260426
+image_size: 512
+batch_size: 4
+grad_accum: 4
+num_workers: 8
+device: cuda
+amp: true
+max_text_bytes: 16
+payload_bits: 256
+train_dir: data/train_images
+val_dir: data/val_images
+artifacts_dir: artifacts/mlwm_v1
+promoted_dir: artifacts/promoted
+models_dir: resources/models/neural_wm
+optimizer:
+ lr: 0.0002
+ weight_decay: 0.00001
+loss:
+ payload_weight: 5.0
+ image_weight: 1.0
+ tv_weight: 0.05
+ residual_weight: 0.1
+stages:
+ warmup:
+ enabled: true
+ epochs: 10
+ freeze_encoder: true
+ fixed_residual_scale: 0.02
+ attack_strength: medium
+ main:
+ enabled: true
+ epochs: 40
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: true
+ epochs: 30
+ freeze_encoder: false
+ attack_strength: hard
+ finalize:
+ enabled: true
+ epochs: 10
+ freeze_encoder: false
+ attack_strength: mixed
+attack:
+ ops_per_sample:
+ min: 2
+ max: 5
+ jpeg_quality: [30, 95]
+ webp_quality: [25, 90]
+ resize_scale: [0.45, 1.4]
+ crop_keep: [0.8, 1.0]
+ rotation_deg: [-7.0, 7.0]
+ perspective_ratio: [0.0, 0.04]
+ gaussian_blur_sigma: [0.0, 2.0]
+ motion_blur_ksize: [3, 9]
+ gaussian_noise_std: [0.0, 0.0314]
+ overlay_area: [0.03, 0.15]
+ overlay_alpha: [0.15, 0.5]
diff --git a/configs/mlwm/boost_5090.yaml b/configs/mlwm/boost_5090.yaml
new file mode 100644
index 0000000..69e75b1
--- /dev/null
+++ b/configs/mlwm/boost_5090.yaml
@@ -0,0 +1,35 @@
+extends: configs/mlwm/continue_5090.yaml
+model:
+ residual_scale: 0.058823529411764705
+optimizer:
+ lr: 0.00003
+ weight_decay: 0.00001
+stages:
+ warmup:
+ enabled: false
+ main:
+ enabled: false
+ hard_negative:
+ enabled: true
+ epochs: 24
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ encoder_teacher_weight: 150.0
+ encoder_teacher_scale: 0.034
+ loss:
+ payload_weight: 14.0
+ image_weight: 0.45
+ tv_weight: 0.012
+ residual_weight: 0.015
+ finalize:
+ enabled: true
+ epochs: 12
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 16.0
+ image_weight: 0.65
+ tv_weight: 0.018
+ residual_weight: 0.025
diff --git a/configs/mlwm/bootstrap_5090.yaml b/configs/mlwm/bootstrap_5090.yaml
new file mode 100644
index 0000000..5d79cfc
--- /dev/null
+++ b/configs/mlwm/bootstrap_5090.yaml
@@ -0,0 +1,45 @@
+extends: configs/mlwm/easy_5090.yaml
+stages:
+ warmup:
+ enabled: true
+ epochs: 3
+ freeze_encoder: true
+ fixed_residual_scale: 0.04
+ attack_strength: clean
+ main:
+ enabled: true
+ epochs: 10
+ freeze_encoder: false
+ freeze_decoder: true
+ encoder_teacher_weight: 20000.0
+ encoder_teacher_scale: 0.04
+ attack_strength: clean
+ loss:
+ payload_weight: 1.0
+ image_weight: 0.0
+ tv_weight: 0.0
+ residual_weight: 0.0
+ hard_negative:
+ enabled: true
+ epochs: 12
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ encoder_teacher_weight: 800.0
+ encoder_teacher_scale: 0.035
+ loss:
+ payload_weight: 8.0
+ image_weight: 0.4
+ tv_weight: 0.01
+ residual_weight: 0.01
+ finalize:
+ enabled: true
+ epochs: 4
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 8.0
+ image_weight: 0.8
+ tv_weight: 0.02
+ residual_weight: 0.03
diff --git a/configs/mlwm/cloud_5090_curriculum.yaml b/configs/mlwm/cloud_5090_curriculum.yaml
new file mode 100644
index 0000000..87747fa
--- /dev/null
+++ b/configs/mlwm/cloud_5090_curriculum.yaml
@@ -0,0 +1,55 @@
+extends: configs/mlwm/base.yaml
+image_size: 512
+batch_size: 10
+grad_accum: 2
+num_workers: 16
+model:
+ residual_scale: 0.0392156862745098
+optimizer:
+ lr: 0.0002
+ weight_decay: 0.00001
+loss:
+ payload_weight: 8.0
+ image_weight: 0.8
+ tv_weight: 0.015
+ residual_weight: 0.03
+dashboard:
+ status_interval_batches: 1
+ sample_interval_batches: 10
+stages:
+ warmup:
+ enabled: true
+ epochs: 5
+ freeze_encoder: true
+ fixed_residual_scale: 0.035
+ attack_strength: medium
+ main:
+ enabled: true
+ epochs: 30
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: true
+ epochs: 20
+ freeze_encoder: false
+ attack_strength: hard
+ finalize:
+ enabled: true
+ epochs: 5
+ freeze_encoder: false
+ attack_strength: mixed
+attack:
+ ops_per_sample:
+ min: 1
+ max: 3
+ jpeg_quality: [45, 95]
+ webp_quality: [40, 90]
+ resize_scale: [0.55, 1.35]
+ crop_keep: [0.85, 1.0]
+ rotation_deg: [-4.0, 4.0]
+ perspective_ratio: [0.0, 0.025]
+ gaussian_blur_sigma: [0.0, 1.2]
+ motion_blur_ksize: [3, 7]
+ gaussian_noise_std: [0.0, 0.02]
+ overlay_area: [0.02, 0.12]
+ overlay_alpha: [0.12, 0.4]
diff --git a/configs/mlwm/continue_5090.yaml b/configs/mlwm/continue_5090.yaml
new file mode 100644
index 0000000..93fac57
--- /dev/null
+++ b/configs/mlwm/continue_5090.yaml
@@ -0,0 +1,33 @@
+extends: configs/mlwm/easy_5090.yaml
+optimizer:
+ lr: 0.00005
+ weight_decay: 0.00001
+stages:
+ warmup:
+ enabled: false
+ main:
+ enabled: false
+ hard_negative:
+ enabled: true
+ epochs: 30
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ encoder_teacher_weight: 300.0
+ encoder_teacher_scale: 0.032
+ loss:
+ payload_weight: 10.0
+ image_weight: 0.6
+ tv_weight: 0.015
+ residual_weight: 0.02
+ finalize:
+ enabled: true
+ epochs: 16
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 12.0
+ image_weight: 0.8
+ tv_weight: 0.02
+ residual_weight: 0.03
diff --git a/configs/mlwm/crop_decoder_5090.yaml b/configs/mlwm/crop_decoder_5090.yaml
new file mode 100644
index 0000000..a7bedf0
--- /dev/null
+++ b/configs/mlwm/crop_decoder_5090.yaml
@@ -0,0 +1,46 @@
+extends: configs/mlwm/boost_5090.yaml
+optimizer:
+ lr: 0.00004
+ weight_decay: 0.00001
+stages:
+ warmup:
+ enabled: false
+ main:
+ enabled: false
+ hard_negative:
+ enabled: true
+ epochs: 18
+ freeze_encoder: true
+ use_fixed_residual: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 14.0
+ image_weight: 0.0
+ tv_weight: 0.0
+ residual_weight: 0.0
+ finalize:
+ enabled: true
+ epochs: 8
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 12.0
+ image_weight: 0.65
+ tv_weight: 0.018
+ residual_weight: 0.025
+attack:
+ enabled_ops:
+ - crop
+ - screenshot_sim
+ - color_jitter
+ ops_per_sample:
+ min: 1
+ max: 1
+ crop_keep: [0.96, 1.0]
+ resize_scale: [0.85, 1.15]
+ jpeg_quality: [75, 95]
+ webp_quality: [75, 95]
+ gaussian_blur_sigma: [0.0, 0.35]
+ gaussian_noise_std: [0.0, 0.004]
diff --git a/configs/mlwm/crop_focus_5090.yaml b/configs/mlwm/crop_focus_5090.yaml
new file mode 100644
index 0000000..4d34983
--- /dev/null
+++ b/configs/mlwm/crop_focus_5090.yaml
@@ -0,0 +1,47 @@
+extends: configs/mlwm/boost_5090.yaml
+optimizer:
+ lr: 0.00003
+ weight_decay: 0.00001
+stages:
+ warmup:
+ enabled: false
+ main:
+ enabled: false
+ hard_negative:
+ enabled: true
+ epochs: 30
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ encoder_teacher_weight: 80.0
+ encoder_teacher_scale: 0.034
+ loss:
+ payload_weight: 18.0
+ image_weight: 0.5
+ tv_weight: 0.012
+ residual_weight: 0.015
+ finalize:
+ enabled: true
+ epochs: 10
+ freeze_encoder: false
+ freeze_decoder: false
+ attack_strength: medium
+ loss:
+ payload_weight: 14.0
+ image_weight: 0.75
+ tv_weight: 0.018
+ residual_weight: 0.025
+attack:
+ enabled_ops:
+ - crop
+ - screenshot_sim
+ - color_jitter
+ ops_per_sample:
+ min: 1
+ max: 2
+ crop_keep: [0.92, 1.0]
+ resize_scale: [0.75, 1.25]
+ jpeg_quality: [70, 95]
+ webp_quality: [70, 95]
+ gaussian_blur_sigma: [0.0, 0.4]
+ gaussian_noise_std: [0.0, 0.006]
diff --git a/configs/mlwm/easy_5090.yaml b/configs/mlwm/easy_5090.yaml
new file mode 100644
index 0000000..24e78bb
--- /dev/null
+++ b/configs/mlwm/easy_5090.yaml
@@ -0,0 +1,49 @@
+extends: configs/mlwm/base.yaml
+image_size: 448
+batch_size: 8
+grad_accum: 2
+num_workers: 12
+model:
+ residual_scale: 0.047058823529411764
+optimizer:
+ lr: 0.0002
+ weight_decay: 0.00001
+loss:
+ payload_weight: 10.0
+ image_weight: 0.6
+ tv_weight: 0.01
+ residual_weight: 0.02
+dashboard:
+ status_interval_batches: 1
+ sample_interval_batches: 10
+stages:
+ warmup:
+ enabled: true
+ epochs: 3
+ freeze_encoder: true
+ fixed_residual_scale: 0.04
+ attack_strength: medium
+ main:
+ enabled: true
+ epochs: 12
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: false
+ finalize:
+ enabled: false
+attack:
+ ops_per_sample:
+ min: 0
+ max: 1
+ jpeg_quality: [75, 95]
+ webp_quality: [70, 95]
+ resize_scale: [0.8, 1.2]
+ crop_keep: [0.95, 1.0]
+ rotation_deg: [-1.0, 1.0]
+ perspective_ratio: [0.0, 0.01]
+ gaussian_blur_sigma: [0.0, 0.5]
+ motion_blur_ksize: [3, 5]
+ gaussian_noise_std: [0.0, 0.008]
+ overlay_area: [0.01, 0.04]
+ overlay_alpha: [0.1, 0.25]
diff --git a/configs/mlwm/export.yaml b/configs/mlwm/export.yaml
new file mode 100644
index 0000000..c08283e
--- /dev/null
+++ b/configs/mlwm/export.yaml
@@ -0,0 +1,6 @@
+extends: configs/mlwm/base.yaml
+export:
+ opset: 18
+ export_encoder: true
+ export_decoder: true
+ verify_with_onnxruntime: true
diff --git a/configs/mlwm/hard_negative.yaml b/configs/mlwm/hard_negative.yaml
new file mode 100644
index 0000000..27e5af3
--- /dev/null
+++ b/configs/mlwm/hard_negative.yaml
@@ -0,0 +1,16 @@
+extends: configs/mlwm/base.yaml
+stages:
+ warmup:
+ enabled: false
+ main:
+ enabled: false
+ hard_negative:
+ enabled: true
+ epochs: 30
+ freeze_encoder: false
+ attack_strength: hard
+ finalize:
+ enabled: true
+ epochs: 10
+ freeze_encoder: false
+ attack_strength: mixed
diff --git a/configs/mlwm/main.yaml b/configs/mlwm/main.yaml
new file mode 100644
index 0000000..bf92e5a
--- /dev/null
+++ b/configs/mlwm/main.yaml
@@ -0,0 +1 @@
+extends: configs/mlwm/base.yaml
diff --git a/configs/mlwm/main_only_5090.yaml b/configs/mlwm/main_only_5090.yaml
new file mode 100644
index 0000000..a166de5
--- /dev/null
+++ b/configs/mlwm/main_only_5090.yaml
@@ -0,0 +1,26 @@
+extends: configs/mlwm/bootstrap_5090.yaml
+stages:
+ warmup:
+ enabled: true
+ epochs: 3
+ freeze_encoder: true
+ use_fixed_residual: true
+ fixed_residual_scale: 0.04
+ attack_strength: clean
+ main:
+ enabled: true
+ epochs: 12
+ freeze_encoder: false
+ freeze_decoder: true
+ encoder_teacher_weight: 20000.0
+ encoder_teacher_scale: 0.04
+ attack_strength: clean
+ loss:
+ payload_weight: 1.0
+ image_weight: 0.0
+ tv_weight: 0.0
+ residual_weight: 0.0
+ hard_negative:
+ enabled: false
+ finalize:
+ enabled: false
diff --git a/configs/mlwm/smoke.yaml b/configs/mlwm/smoke.yaml
new file mode 100644
index 0000000..9567f40
--- /dev/null
+++ b/configs/mlwm/smoke.yaml
@@ -0,0 +1,20 @@
+extends: configs/mlwm/base.yaml
+batch_size: 2
+grad_accum: 1
+image_size: 448
+stages:
+ warmup:
+ enabled: true
+ epochs: 1
+ freeze_encoder: true
+ fixed_residual_scale: 0.02
+ attack_strength: medium
+ main:
+ enabled: true
+ epochs: 1
+ freeze_encoder: false
+ attack_strength: medium
+ hard_negative:
+ enabled: false
+ finalize:
+ enabled: false
diff --git a/docs/mlwm/architecture.md b/docs/mlwm/architecture.md
new file mode 100644
index 0000000..c79cb15
--- /dev/null
+++ b/docs/mlwm/architecture.md
@@ -0,0 +1,27 @@
+# MLWM v1 Architecture
+
+MLWM v1 adds a neural short-payload image watermark engine next to the legacy DCT engine.
+
+## Core design
+
+- Payload is fixed to 256 bits after framing, RS encoding, and deterministic bit whitening.
+- Text payload is limited to 16 UTF-8 bytes.
+- Legacy frequency-domain synchronization remains the geometric anchor.
+- Neural embed/extract only handles payload recovery.
+- Runtime uses ONNX for desktop inference and PyTorch for training/export.
+
+## Runtime flow
+
+1. Encode short text into a fixed frame with CRC32 and RS parity, then whiten the 256 training bits.
+2. Run the encoder network to predict a residual map.
+3. Apply the residual to the image and inject the classical sync template.
+4. At extraction time, detect the sync template, rectify the image, and score several candidate views.
+5. Aggregate decoder logits, unwhiten the bits, RS-decode, and CRC-check the payload.
+6. If neural decode fails in `auto`, fall back to the legacy engine.
+
+## Training flow
+
+- Stage A: decoder warmup with deterministic fixed residuals.
+- Stage B: joint encoder/decoder training with medium attacks.
+- Stage C: hard-negative fine-tune on the strongest attack chains.
+- Stage D: confidence calibration and export freeze.
diff --git a/docs/mlwm/benchmark_protocol.md b/docs/mlwm/benchmark_protocol.md
new file mode 100644
index 0000000..ca9ead9
--- /dev/null
+++ b/docs/mlwm/benchmark_protocol.md
@@ -0,0 +1,30 @@
+# MLWM v1 Benchmark Protocol
+
+Benchmark each engine on the same image corpus with the same payload family.
+
+## Attack suite
+
+- Clean
+- JPEG q75
+- JPEG q50
+- WEBP q50
+- Resize to 50 percent then scale back
+- 10 percent edge crop then scale back
+- Rotation +/- 3 degrees + JPEG q75
+- Blur / noise medium
+- Corner overlay <= 15 percent area
+- Screenshot simulation
+
+## Success criteria
+
+- Clean exact-match >= 99.5%
+- JPEG q75 >= 98%
+- JPEG q50 >= 95%
+- WEBP q50 >= 93%
+- Resize 50 percent >= 92%
+- Crop 10 percent >= 88%
+- Rotation + JPEG >= 90%
+- Blur / noise >= 90%
+- Overlay <= 15 percent >= 80%
+
+Benchmarks should record engine used, fallback behavior, and confidence output where available.
diff --git a/docs/mlwm/traceability.md b/docs/mlwm/traceability.md
new file mode 100644
index 0000000..39386ce
--- /dev/null
+++ b/docs/mlwm/traceability.md
@@ -0,0 +1,50 @@
+# MLWM v1 Traceability Rules
+
+## Branching
+
+- Do not develop directly on `master`
+- Use feature branches for MLWM changes
+- Merge feature branches into the MLWM integration branch before merging to `master`
+
+## Commits
+
+- Keep commits single-topic and atomic
+- Use conventional prefixes such as `feat(mlwm)`, `feat(ui)`, `docs(mlwm)`, `test(mlwm)`
+
+## Run manifests
+
+Every training run writes a `run_manifest.json` with:
+
+- git branch
+- git commit SHA
+- dirty flag
+- Python version
+- CUDA version
+- GPU name
+- config hash
+- dataset manifest hash
+- timing data
+- model digests
+
+## Git tracking rules
+
+Tracked in Git:
+
+- source code
+- docs
+- configs
+- promoted model manifests
+- benchmark summaries
+
+Tracked through Git LFS:
+
+- promoted ONNX files
+- promoted checkpoints
+
+Ignored:
+
+- raw datasets
+- intermediate checkpoints
+- temporary exports
+- tensorboard logs
+- large per-run artifacts outside promoted releases
diff --git a/docs/mlwm/training.md b/docs/mlwm/training.md
new file mode 100644
index 0000000..ecd5e0b
--- /dev/null
+++ b/docs/mlwm/training.md
@@ -0,0 +1,77 @@
+# MLWM v1 Training
+
+## Recommended environment
+
+- Python 3.12 x64
+- NVIDIA GeForce RTX 5060 8GB
+- CUDA-enabled PyTorch
+
+## Install
+
+```bash
+py -3.12 -m venv .venv-ml
+.venv-ml\Scripts\activate
+python -m pip install --upgrade pip
+python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128
+python -m pip install -r blind_watermark/requirements-ml.txt
+```
+
+For packaging/runtime checks:
+
+```bash
+py -3.12 -m venv .venv-pack
+.venv-pack\Scripts\activate
+python -m pip install --upgrade pip
+python -m pip install -r blind_watermark/requirements-onnx.txt pyinstaller
+```
+
+Verify GPU availability:
+
+```bash
+.venv-ml\Scripts\python.exe -c "import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"
+```
+
+## Dataset
+
+- Place natural images under `data/train_images`
+- Validation images go under `data/val_images`
+- No labels are required; payloads are generated synthetically per sample
+
+Use the dataset preparation helper when importing images from one or more local
+directories:
+
+```bash
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.prepare_dataset --source D:\images\natural --out-dir data --min-size 512 --val-ratio 0.1 --copy-mode copy --clean
+```
+
+The helper filters unreadable or small images, de-duplicates by SHA-256, writes
+`data/dataset_manifest.json`, and leaves raw datasets outside Git.
+
+Unsplash Lite ships metadata first. Download resized image files from
+`photos.csv000`, then prepare train/val directories:
+
+```bash
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.download_unsplash_lite --photos-file C:\Users\Ha183\Downloads\Compressed\unsplash-research-dataset-lite-latest\photos.csv000 --out-dir data\unsplash_lite_raw --limit 5000 --width 1024 --quality 85 --workers 8
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.prepare_dataset --source data\unsplash_lite_raw --out-dir data --min-size 512 --val-ratio 0.1 --copy-mode hardlink --clean
+```
+
+## Smoke run
+
+```bash
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.train --config configs/mlwm/smoke.yaml
+```
+
+## Main run
+
+```bash
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.train --config configs/mlwm/main.yaml
+```
+
+## Export
+
+```bash
+.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.export_onnx --config configs/mlwm/export.yaml --checkpoint
+```
+
+Use `--out-dir artifacts/mlwm_v1/tmp/` for smoke exports. Only write to
+`resources/models/neural_wm` when promoting a benchmarked model.
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
new file mode 100644
index 0000000..a32dff9
--- /dev/null
+++ b/docs/wiki/Home.md
@@ -0,0 +1,30 @@
+# LuminCrypt Wiki
+
+This wiki tracks the current repository branches, release posture, MLWM research work, and operational runbooks.
+
+## Current branch map
+
+| Branch | Role | Current status |
+|---|---|---|
+| `master` | Stable protected mainline | Production baseline for Unicode detection, text watermarking, and legacy image watermarking |
+| `codex/mlwm-v1` | MLWM v1 research and integration branch | Draft PR branch for neural robust image watermarking; CI passing; training deferred until a suitable GPU window |
+
+## Core pages
+
+- [Master Branch](Master-Branch.md)
+- [MLWM v1 Branch](MLWM-v1-Branch.md)
+- [MLWM Training Runbook](MLWM-Training-Runbook.md)
+
+## Governance snapshot
+
+- Default branch: `master`
+- Protection: active ruleset `protect-master`
+- Required checks:
+ - `Test robust watermark engine`
+ - `MLWM unit tests`
+ - `Typecheck`
+- Merge policy: pull request required, conversations must be resolved, force push and branch deletion are blocked.
+
+## Current priority
+
+The next technical milestone is first full MLWM training on the prepared Unsplash Lite subset. Training is intentionally paused until there is enough uninterrupted local RTX 5060 time.
diff --git a/docs/wiki/MLWM-Training-Runbook.md b/docs/wiki/MLWM-Training-Runbook.md
new file mode 100644
index 0000000..a371c15
--- /dev/null
+++ b/docs/wiki/MLWM-Training-Runbook.md
@@ -0,0 +1,135 @@
+# MLWM Training Runbook
+
+This runbook records the local training workflow for the neural robust image watermark engine.
+
+## Environment
+
+Training environment:
+
+```powershell
+py -3.12 -m venv .venv-ml
+.\.venv-ml\Scripts\python.exe -m pip install --upgrade pip
+.\.venv-ml\Scripts\python.exe -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128
+.\.venv-ml\Scripts\python.exe -m pip install -r blind_watermark\requirements-ml.txt
+```
+
+Runtime/package environment:
+
+```powershell
+py -3.12 -m venv .venv-pack
+.\.venv-pack\Scripts\python.exe -m pip install --upgrade pip
+.\.venv-pack\Scripts\python.exe -m pip install -r blind_watermark\requirements-onnx.txt pyinstaller
+```
+
+GPU verification:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -c "import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"
+```
+
+Expected local result:
+
+- CUDA available: `True`
+- GPU: `NVIDIA GeForce RTX 5060 Laptop GPU`
+
+## Unsplash Lite ingestion
+
+The Unsplash Lite package at:
+
+```text
+C:\Users\Ha183\Downloads\Compressed\unsplash-research-dataset-lite-latest
+```
+
+contains metadata TSV/CSV files, not image files. Download resized images from `photos.csv000`:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.download_unsplash_lite --photos-file "C:\Users\Ha183\Downloads\Compressed\unsplash-research-dataset-lite-latest\photos.csv000" --out-dir data\unsplash_lite_raw --limit 5000 --width 1024 --quality 85 --workers 12
+```
+
+Current local download result:
+
+- Requested: `5000`
+- OK: `4999`
+- Failed: `1`
+
+## Dataset preparation
+
+Prepare train/val directories:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.prepare_dataset --source data\unsplash_lite_raw --out-dir data --min-size 512 --val-ratio 0.1 --copy-mode hardlink --clean
+```
+
+Current local prepared dataset:
+
+- Train: `4488`
+- Validation: `499`
+- Manifest: `data/dataset_manifest.json`
+
+Use `--clean` to prevent older smoke samples from mixing into the current dataset.
+
+## Smoke training
+
+Run a short validation pass before long training:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.train --config configs\mlwm\smoke.yaml
+```
+
+Latest real-data smoke result:
+
+- Run: `artifacts/mlwm_v1/runs/20260426T074520+0000_6056931`
+- Best epoch: `2`
+- Best score: `0.680125`
+
+## Full training
+
+Run only when the RTX 5060 can be occupied for several uninterrupted hours:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.train --config configs\mlwm\main.yaml
+```
+
+Monitor:
+
+- GPU memory usage
+- `metrics_epoch.csv`
+- validation payload accuracy
+- exact match rate
+- checkpoint creation
+- `run_manifest.json`
+
+If GPU memory fails, reduce `image_size` to `448` for the first full experiment.
+
+## Export
+
+Export from the best checkpoint to a temporary candidate directory first:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.export_onnx --config configs\mlwm\export.yaml --checkpoint --out-dir artifacts\mlwm_v1\tmp\candidate_001
+```
+
+Check runtime readiness:
+
+```powershell
+.\.venv-pack\Scripts\python.exe blind_watermark\bwm_helper.py --mode check --models-dir artifacts\mlwm_v1\tmp\candidate_001
+```
+
+Only copy ONNX files into `resources/models/neural_wm` after benchmark acceptance.
+
+## Promotion checklist
+
+A candidate model can be promoted only when:
+
+- ONNX export succeeds as single-file `encoder.onnx` and `decoder.onnx`.
+- Helper reports `neuralReady=true`.
+- Benchmark results meet or clearly justify the acceptance threshold.
+- `model.json` records:
+ - model version
+ - Git commit
+ - dataset manifest hash
+ - config hash
+ - ONNX SHA-256 values
+ - benchmark summary
+
+Do not commit raw datasets, intermediate runs, or temporary exports.
diff --git a/docs/wiki/MLWM-v1-Branch.md b/docs/wiki/MLWM-v1-Branch.md
new file mode 100644
index 0000000..e6ddee3
--- /dev/null
+++ b/docs/wiki/MLWM-v1-Branch.md
@@ -0,0 +1,108 @@
+# MLWM v1 Branch
+
+`codex/mlwm-v1` is the active neural image watermarking research and integration branch.
+
+## Purpose
+
+This branch adds a learning-assisted robust image watermark engine next to the existing legacy DCT/Reed-Solomon image watermark engine.
+
+The branch should stay separate from `master` until a first trained model can be exported and benchmarked.
+
+## Current implementation status
+
+Implemented:
+
+- `engine='auto' | 'legacy' | 'neural'` dispatch in the Python watermark engine
+- Structured Python return values with:
+ - `engine_used`
+ - `fallback_used`
+ - `confidence`
+ - `diagnostics`
+- Electron IPC and preload type support for image watermark engine selection
+- Renderer UI for `Auto`, `Legacy`, and `Neural`
+- MLWM modules for:
+ - payload codec
+ - attack simulation
+ - dataset loading
+ - model definitions
+ - training
+ - ONNX export
+ - runtime inference
+ - benchmarking
+ - traceability
+- GitHub Actions checks:
+ - `Test robust watermark engine`
+ - `MLWM unit tests`
+ - `Typecheck`
+- Dataset preparation tooling
+- Unsplash Lite metadata downloader
+
+Not completed:
+
+- Full main training run
+- Promoted `encoder.onnx` and `decoder.onnx`
+- Benchmark report against the release thresholds
+- `resources/models/neural_wm/model.json` promotion from `pending-training`
+
+## Local environment status
+
+Validated locally:
+
+- Python 3.12 virtual environments:
+ - `.venv-ml`
+ - `.venv-pack`
+- PyTorch CUDA:
+ - `torch 2.11.0+cu128`
+ - GPU detected: `NVIDIA GeForce RTX 5060 Laptop GPU`
+- ONNX Runtime:
+ - `onnxruntime 1.25.0`
+- Smoke training completed on prepared Unsplash Lite data.
+- Temporary single-file ONNX export completed.
+- Helper check can report `neuralReady=true` when pointed at a temporary exported model directory.
+
+## Prepared data status
+
+The current local training data was prepared from Unsplash Lite metadata.
+
+Downloaded:
+
+- Requested: `5000`
+- Successful: `4999`
+- Failed: `1`
+
+Prepared dataset:
+
+- `data/train_images`: `4488`
+- `data/val_images`: `499`
+- Manifest: `data/dataset_manifest.json`
+
+The `data/` directory is intentionally ignored by Git.
+
+## Smoke training result
+
+Latest real-data smoke run:
+
+- Run directory: `artifacts/mlwm_v1/runs/20260426T074520+0000_6056931`
+- Best checkpoint: `best.ckpt`
+- Best epoch: `2`
+- Best score: `0.680125`
+- Validation payload accuracy: about `0.680`
+- Exact match: `0.0`
+
+This confirms the training pipeline is operational. It is not a usable production model.
+
+## Next milestone
+
+Run the first full training pass when the local GPU can be occupied for several hours:
+
+```powershell
+.\.venv-ml\Scripts\python.exe -m blind_watermark.mlwm.train --config configs\mlwm\main.yaml
+```
+
+After training:
+
+1. Export the best checkpoint to a temporary candidate directory.
+2. Run benchmark evaluation.
+3. Promote the model only if it meets the release thresholds.
+4. Update `resources/models/neural_wm/model.json` with hashes, commit, dataset manifest hash, and benchmark summary.
+5. Keep PR #1 as draft until the model promotion decision is clear.
diff --git a/docs/wiki/Master-Branch.md b/docs/wiki/Master-Branch.md
new file mode 100644
index 0000000..99935b8
--- /dev/null
+++ b/docs/wiki/Master-Branch.md
@@ -0,0 +1,56 @@
+# Master Branch
+
+`master` is the stable protected branch for LuminCrypt.
+
+## Purpose
+
+`master` should remain the reliable application baseline. It should not receive experimental ML training code, unbenchmarked models, or large generated artifacts directly.
+
+## Current capabilities
+
+- Unicode text detection and risk scoring
+- Text watermark embedding and extraction
+- Text cleaning and compare workflows
+- Batch processing workflows
+- Legacy robust image watermarking through the Python helper
+- Electron + React desktop shell
+
+## Current protection rules
+
+The branch is protected by the repository ruleset `protect-master`.
+
+Required behavior:
+
+- Changes must enter through a pull request.
+- Required status checks must pass:
+ - `Test robust watermark engine`
+ - `MLWM unit tests`
+ - `Typecheck`
+- Review conversations must be resolved before merging.
+- Force pushes are blocked.
+- Branch deletion is blocked.
+
+Approval count is currently `0`, which keeps the repository usable as a single-owner project while still preventing direct pushes and failing-check merges.
+
+## Merge readiness checklist
+
+Before merging a PR into `master`:
+
+- PR branch is current with `master`.
+- Required checks are green.
+- The PR description states validation commands or relevant CI runs.
+- No raw datasets, intermediate checkpoints, temporary exports, or local virtual environments are included.
+- Any promoted model artifact has an associated manifest, benchmark summary, and Git commit reference.
+
+## MLWM visibility policy on master
+
+Until a benchmarked neural model is promoted:
+
+- `Legacy` remains the production image watermark engine.
+- `Auto` may keep fallback behavior.
+- `Neural` UI and diagnostics may remain visible, but must clearly report when the model is not ready.
+- `neuralReady=false` must not be presented as a completed user-facing capability.
+
+## Current relationship to `codex/mlwm-v1`
+
+`codex/mlwm-v1` is the integration branch for MLWM v1. It should remain a draft PR branch until the first full model training, export, and benchmark pass are available.
diff --git a/electron-builder.yml b/electron-builder.yml
index 6a6814b..6ac684a 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -17,6 +17,10 @@ extraResources:
to: bin/
filter:
- '**/*'
+ - from: resources/models/
+ to: models/
+ filter:
+ - '**/*'
win:
signAndEditExecutable: false
executableName: LuminCrypt
diff --git a/package.json b/package.json
index faa0a95..5192e03 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,14 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
- "build:python": "pip install numpy opencv-python scipy reedsolo pyinstaller && pyinstaller --distpath resources/bin --workpath build/pyinstaller build_bwm.spec",
+ "build:python": "pip install -r blind_watermark/requirements-onnx.txt pyinstaller && pyinstaller --distpath resources/bin --workpath build/pyinstaller build_bwm.spec",
+ "mlwm:train": "python -m blind_watermark.mlwm.train --config configs/mlwm/main.yaml",
+ "mlwm:train:smoke": "python -m blind_watermark.mlwm.train --config configs/mlwm/smoke.yaml",
+ "mlwm:prepare-data": "python -m blind_watermark.mlwm.prepare_dataset",
+ "mlwm:download-unsplash": "python -m blind_watermark.mlwm.download_unsplash_lite",
+ "mlwm:export": "python -m blind_watermark.mlwm.export_onnx --config configs/mlwm/export.yaml",
+ "mlwm:bench": "python -m blind_watermark.mlwm.bench",
+ "mlwm:dashboard": "python -m blind_watermark.mlwm.dashboard",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
diff --git a/resources/models/neural_wm/decoder.onnx b/resources/models/neural_wm/decoder.onnx
new file mode 100644
index 0000000..866fbf9
--- /dev/null
+++ b/resources/models/neural_wm/decoder.onnx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55ca146d887f2acd1911e59b21d1a205e5971064e2ad1fd4986302de03c17a14
+size 2295672
diff --git a/resources/models/neural_wm/encoder.onnx b/resources/models/neural_wm/encoder.onnx
new file mode 100644
index 0000000..4a9c685
--- /dev/null
+++ b/resources/models/neural_wm/encoder.onnx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8dbda9a72b42ad133f2e8cfe5a5e3c167b4a4073bab9ebde142b471429ff7a05
+size 5754726
diff --git a/resources/models/neural_wm/model.json b/resources/models/neural_wm/model.json
new file mode 100644
index 0000000..73a40cd
--- /dev/null
+++ b/resources/models/neural_wm/model.json
@@ -0,0 +1,102 @@
+{
+ "modelVersion": "mlwm-v1-alpha1",
+ "status": "ready",
+ "engine": "neural",
+ "imageSize": 512,
+ "trainedImageSize": 448,
+ "trainingConfigId": "56a62d8d8172c8e77c3c894c5ee2b6631a796a482a0638ef6e7db22a31508e93",
+ "datasetManifestHash": "66e398a984115421eb783c1bfeaa61140be7d0e482ef7850fcaabd3ec22c1136",
+ "exportTime": "2026-04-27T04:15:19+00:00",
+ "checkpoint": {
+ "path": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "sha256": "d752bcc4708e7b74e661bafe2b95e4ac4f6d2e04da6edca918f49ac56792d9b5",
+ "bestEpoch": 18,
+ "bestMetric": 2.9676029265873014
+ },
+ "encoder": {
+ "path": "encoder.onnx",
+ "sha256": "8dbda9a72b42ad133f2e8cfe5a5e3c167b4a4073bab9ebde142b471429ff7a05"
+ },
+ "decoder": {
+ "path": "decoder.onnx",
+ "sha256": "55ca146d887f2acd1911e59b21d1a205e5971064e2ad1fd4986302de03c17a14"
+ },
+ "benchmarkSummary": {
+ "cleanDecodeSuccess": 1.0,
+ "mediumDecodeSuccess": 0.9921875,
+ "hardDecodeSuccess": 0.912326388888889,
+ "fullMediumDecodeSuccess": 0.9895833333333334,
+ "fullHardDecodeSuccess": 0.9772135416666666,
+ "fullHardExactMatch": 0.9231770833333334,
+ "fullHardPayloadAcc": 0.9978663126627604
+ },
+ "trainingGitCommit": "6107b38",
+ "exportGitCommit": "eae79611614f5cdde7958d1fd773010ca46cdd23",
+ "evaluations": {
+ "clean": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "clean",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 1.0,
+ "exactMatch": 1.0,
+ "decodeSuccess": 1.0,
+ "createdAt": "2026-04-27T04:07:27+00:00"
+ },
+ "medium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9989802042643229,
+ "exactMatch": 0.9641927083333334,
+ "decodeSuccess": 0.9921875,
+ "createdAt": "2026-04-27T04:07:53+00:00"
+ },
+ "hard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/train_config_resolved.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9915457831488715,
+ "exactMatch": 0.824001736111111,
+ "decodeSuccess": 0.912326388888889,
+ "createdAt": "2026-04-27T04:08:22+00:00"
+ },
+ "fullMedium": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "medium",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9992650349934896,
+ "exactMatch": 0.9615885416666666,
+ "decodeSuccess": 0.9895833333333334,
+ "createdAt": "2026-04-27T04:10:09+00:00"
+ },
+ "fullHard": {
+ "checkpoint": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/best.ckpt",
+ "config": "artifacts/mlwm_v1/runs/20260426T185428+0000_6107b38/eval_full_attack.yaml",
+ "valDir": "data/val_images",
+ "strength": "hard",
+ "repeats": 3,
+ "batches": 96,
+ "samples": 1497,
+ "payloadAcc": 0.9978663126627604,
+ "exactMatch": 0.9231770833333334,
+ "decodeSuccess": 0.9772135416666666,
+ "createdAt": "2026-04-27T04:10:42+00:00"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/index.ts b/src/main/index.ts
index 27322bb..e2dd381 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -6,18 +6,19 @@ import { spawn } from 'child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
-// ─── Simple persistent store ──────────────────────────────────────────────────
let storeCache: Record = {}
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:'])
const ALLOWED_QUALITY = new Set(['invisible', 'balanced', 'robust'])
+const ALLOWED_ENGINE = new Set(['auto', 'legacy', 'neural'])
const ALLOWED_IMG_EXT = new Set(['.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'])
const MAX_TEXT_PAYLOAD = 2_000_000
const MAX_PDF_HTML_B64 = 10_000_000
+const BWM_EXE = process.platform === 'win32' ? 'bwm_helper.exe' : 'bwm_helper'
function isSafeExternalUrl(raw: string): boolean {
try {
- const u = new URL(raw)
- return ALLOWED_EXTERNAL_PROTOCOLS.has(u.protocol)
+ const url = new URL(raw)
+ return ALLOWED_EXTERNAL_PROTOCOLS.has(url.protocol)
} catch {
return false
}
@@ -25,13 +26,9 @@ function isSafeExternalUrl(raw: string): boolean {
function isSafeAppNavigation(raw: string): boolean {
try {
- const u = new URL(raw)
- if (u.protocol === 'file:' || u.protocol === 'devtools:' || u.protocol === 'app:') return true
- if (
- is.dev &&
- (u.protocol === 'http:' || u.protocol === 'https:') &&
- (u.hostname === 'localhost' || u.hostname === '127.0.0.1')
- ) {
+ const url = new URL(raw)
+ if (url.protocol === 'file:' || url.protocol === 'devtools:' || url.protocol === 'app:') return true
+ if (is.dev && (url.protocol === 'http:' || url.protocol === 'https:') && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) {
return true
}
return false
@@ -40,29 +37,27 @@ function isSafeAppNavigation(raw: string): boolean {
}
}
-function isValidPathInput(v: unknown): v is string {
- return typeof v === 'string' && v.trim().length > 0 && v.length <= 4096 && !v.includes('\0')
+function isValidPathInput(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= 4096 && !value.includes('\0')
}
function isAllowedImagePath(pathLike: string): boolean {
- const ext = extname(normalize(pathLike)).toLowerCase()
- return ALLOWED_IMG_EXT.has(ext)
+ return ALLOWED_IMG_EXT.has(extname(normalize(pathLike)).toLowerCase())
}
function sanitizeStoreKey(key: unknown): string | null {
if (typeof key !== 'string') return null
- const k = key.trim()
- if (k.length === 0 || k.length > 128) return null
- if (!/^[a-zA-Z0-9._:-]+$/.test(k)) return null
- return k
+ const trimmed = key.trim()
+ if (trimmed.length === 0 || trimmed.length > 128) return null
+ if (!/^[a-zA-Z0-9._:-]+$/.test(trimmed)) return null
+ return trimmed
}
async function loadStore(): Promise {
try {
- const p = join(app.getPath('userData'), 'settings.json')
- if (existsSync(p)) {
- const raw = await readFile(p, 'utf-8')
- storeCache = JSON.parse(raw)
+ const path = join(app.getPath('userData'), 'settings.json')
+ if (existsSync(path)) {
+ storeCache = JSON.parse(await readFile(path, 'utf-8'))
}
} catch (err) {
console.warn('[store] failed to load settings:', err)
@@ -71,8 +66,8 @@ async function loadStore(): Promise {
async function saveStore(): Promise {
try {
- const p = join(app.getPath('userData'), 'settings.json')
- await writeFile(p, JSON.stringify(storeCache, null, 2), 'utf-8')
+ const path = join(app.getPath('userData'), 'settings.json')
+ await writeFile(path, JSON.stringify(storeCache, null, 2), 'utf-8')
} catch (err) {
console.warn('[store] failed to save settings:', err)
}
@@ -120,6 +115,7 @@ function createWindow(): void {
}
return { action: 'deny' }
})
+
mainWindow.webContents.on('will-navigate', (details) => {
if (!isSafeAppNavigation(details.url)) {
console.warn('[security] blocked navigation to:', details.url)
@@ -127,8 +123,8 @@ function createWindow(): void {
}
})
- if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
- mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']).catch((err) => {
+ if (is.dev && process.env.ELECTRON_RENDERER_URL) {
+ mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL).catch((err) => {
console.error('[window] failed to load dev URL:', err)
})
} else {
@@ -138,88 +134,171 @@ function createWindow(): void {
}
}
+function findBundledExe(): string | null {
+ const path = is.dev
+ ? join(app.getAppPath(), 'resources', 'bin', BWM_EXE)
+ : join(process.resourcesPath, 'bin', BWM_EXE)
+ return existsSync(path) ? path : null
+}
+
+function findBundledModelsDir(): string {
+ return is.dev
+ ? join(app.getAppPath(), 'resources', 'models', 'neural_wm')
+ : join(process.resourcesPath, 'models', 'neural_wm')
+}
+
+async function findPythonExe(): Promise {
+ if (process.platform === 'win32') {
+ for (const cmd of ['python', 'python3', 'py']) {
+ const resolved = await new Promise((resolve) => {
+ const child = spawn('where', [cmd], { windowsHide: true, timeout: 4000, shell: true })
+ let out = ''
+ child.stdout?.on('data', (d: Buffer) => {
+ out += d.toString()
+ })
+ child.on('close', (code) => {
+ if (code !== 0) return resolve(null)
+ const first = out.trim().split('\n')[0]?.trim()
+ resolve(first && first.length > 0 ? first : null)
+ })
+ child.on('error', () => resolve(null))
+ })
+ if (resolved) return resolved
+ }
+ return null
+ }
+
+ for (const cmd of ['python3', 'python']) {
+ const ok = await new Promise((resolve) => {
+ const child = spawn(cmd, ['--version'], { timeout: 4000 })
+ child.on('close', (code) => resolve(code === 0))
+ child.on('error', () => resolve(false))
+ })
+ if (ok) return cmd
+ }
+ return null
+}
+
+type BwmRunner = { mode: 'exe'; exePath: string } | { mode: 'python'; python: string }
+
+async function getRunner(): Promise {
+ const exePath = findBundledExe()
+ if (exePath) return { mode: 'exe', exePath }
+ const python = await findPythonExe()
+ return python ? { mode: 'python', python } : null
+}
+
+function bwmScriptPath(): string {
+ return is.dev ? join(app.getAppPath(), 'blind_watermark', 'bwm_helper.py') : join(process.resourcesPath, 'bwm_helper.py')
+}
+
+function runBwm(runner: BwmRunner, opts: Record): Promise> {
+ return new Promise((resolve) => {
+ const spawnEnv = { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
+ const child =
+ runner.mode === 'exe'
+ ? spawn(runner.exePath, ['--json-stdin'], { windowsHide: true, timeout: 180_000, env: spawnEnv })
+ : spawn(runner.python, [bwmScriptPath(), '--json-stdin'], {
+ windowsHide: true,
+ timeout: 180_000,
+ env: spawnEnv,
+ cwd: is.dev ? join(app.getAppPath(), 'blind_watermark') : process.resourcesPath,
+ })
+
+ let stdout = ''
+ let stderr = ''
+ child.stdout?.on('data', (d: Buffer) => {
+ stdout += d.toString('utf8')
+ })
+ child.stderr?.on('data', (d: Buffer) => {
+ stderr += d.toString('utf8')
+ })
+ child.on('close', (code) => {
+ const lines = stdout.trim().split('\n').filter(Boolean)
+ const lastLine = lines[lines.length - 1] ?? ''
+ try {
+ resolve(JSON.parse(lastLine))
+ } catch {
+ resolve({
+ ok: false,
+ error: lastLine || stderr.trim() || `Process exited with code ${code}`,
+ })
+ }
+ })
+ child.on('error', (err) => {
+ resolve({ ok: false, error: err.message })
+ })
+
+ child.stdin?.write(Buffer.from(JSON.stringify(opts), 'utf-8'))
+ child.stdin?.end()
+ })
+}
+
app.whenReady().then(async () => {
await loadStore()
electronApp.setAppUserModelId('com.lumincrypt.app')
-
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
- // Window control IPC
ipcMain.on('window:minimize', () => {
BrowserWindow.getFocusedWindow()?.minimize()
})
ipcMain.on('window:maximize', () => {
const win = BrowserWindow.getFocusedWindow()
- if (win?.isMaximized()) {
- win.unmaximize()
- } else {
- win?.maximize()
- }
+ if (win?.isMaximized()) win.unmaximize()
+ else win?.maximize()
})
ipcMain.on('window:close', () => {
BrowserWindow.getFocusedWindow()?.close()
})
- // ─── Store IPC ────────────────────────────────────────────────────────────
ipcMain.handle('store:getAll', () => ({ ...storeCache }))
ipcMain.handle('store:get', (_e, key: string) => {
- const k = sanitizeStoreKey(key)
- if (!k) return undefined
- return storeCache[k]
+ const sanitized = sanitizeStoreKey(key)
+ if (!sanitized) return undefined
+ return storeCache[sanitized]
})
ipcMain.handle('store:set', async (_e, key: string, value: unknown) => {
- const k = sanitizeStoreKey(key)
- if (!k) return
- storeCache[k] = value
+ const sanitized = sanitizeStoreKey(key)
+ if (!sanitized) return
+ storeCache[sanitized] = value
await saveStore()
})
- // ─── Clipboard IPC ────────────────────────────────────────────────────────
ipcMain.handle('clipboard:read', () => clipboard.readText())
- // ─── File save dialog IPC ─────────────────────────────────────────────────
- ipcMain.handle(
- 'dialog:saveFile',
- async (_event, content: string, ext: 'json' | 'pdf', defaultName: string) => {
- if (typeof content !== 'string' || content.length > MAX_TEXT_PAYLOAD) {
- return { success: false, error: 'Invalid content payload' }
- }
- if (typeof defaultName !== 'string' || defaultName.length === 0 || defaultName.length > 255) {
- return { success: false, error: 'Invalid file name' }
- }
- const win = BrowserWindow.getFocusedWindow()
- if (!win) return { success: false, error: 'No window' }
-
- const filters =
- ext === 'json'
- ? [{ name: 'JSON Report', extensions: ['json'] }]
- : [{ name: 'PDF Report', extensions: ['pdf'] }]
-
- const { canceled, filePath } = await dialog.showSaveDialog(win, {
- title: 'Save Report',
- defaultPath: defaultName,
- filters,
- })
+ ipcMain.handle('dialog:saveFile', async (_event, content: string, ext: 'json' | 'pdf', defaultName: string) => {
+ if (typeof content !== 'string' || content.length > MAX_TEXT_PAYLOAD) {
+ return { success: false, error: 'Invalid content payload' }
+ }
+ if (typeof defaultName !== 'string' || defaultName.length === 0 || defaultName.length > 255) {
+ return { success: false, error: 'Invalid file name' }
+ }
+ const win = BrowserWindow.getFocusedWindow()
+ if (!win) return { success: false, error: 'No window' }
- if (canceled || !filePath) return { success: false, error: 'Canceled' }
+ const filters = ext === 'json'
+ ? [{ name: 'JSON Report', extensions: ['json'] }]
+ : [{ name: 'PDF Report', extensions: ['pdf'] }]
- try {
- if (ext === 'json') {
- await writeFile(filePath, content, 'utf-8')
- } else {
- await writeFile(filePath, Buffer.from(content, 'base64'))
- }
- return { success: true, filePath }
- } catch (err) {
- return { success: false, error: String(err) }
- }
+ const { canceled, filePath } = await dialog.showSaveDialog(win, {
+ title: 'Save Report',
+ defaultPath: defaultName,
+ filters,
+ })
+ if (canceled || !filePath) return { success: false, error: 'Canceled' }
+
+ try {
+ if (ext === 'json') await writeFile(filePath, content, 'utf-8')
+ else await writeFile(filePath, Buffer.from(content, 'base64'))
+ return { success: true, filePath }
+ } catch (err) {
+ return { success: false, error: String(err) }
}
- )
+ })
- // ─── CSV save dialog IPC ──────────────────────────────────────────────────
ipcMain.handle('dialog:saveCSV', async (_event, content: string, defaultName: string) => {
if (typeof content !== 'string' || content.length > MAX_TEXT_PAYLOAD) {
return { success: false, error: 'Invalid CSV payload' }
@@ -231,14 +310,13 @@ app.whenReady().then(async () => {
if (!win) return { success: false, error: 'No window' }
const { canceled, filePath } = await dialog.showSaveDialog(win, {
- title: '导出 CSV 报告',
+ title: 'Save CSV',
defaultPath: defaultName,
filters: [{ name: 'CSV File', extensions: ['csv'] }],
})
if (canceled || !filePath) return { success: false, error: 'Canceled' }
try {
- // Write UTF-8 BOM for Excel compatibility
await writeFile(filePath, '\uFEFF' + content, 'utf-8')
return { success: true, filePath }
} catch (err) {
@@ -246,7 +324,6 @@ app.whenReady().then(async () => {
}
})
- // ─── PDF from HTML via printToPDF ─────────────────────────────────────────
ipcMain.handle('dialog:exportPDF', async (_event, htmlB64: string, defaultName: string) => {
if (typeof htmlB64 !== 'string' || htmlB64.length === 0 || htmlB64.length > MAX_PDF_HTML_B64) {
return { success: false, error: 'Invalid PDF payload' }
@@ -258,7 +335,7 @@ app.whenReady().then(async () => {
if (!win) return { success: false, error: 'No window' }
const { canceled, filePath } = await dialog.showSaveDialog(win, {
- title: '导出 PDF 报告',
+ title: 'Save PDF',
defaultPath: defaultName,
filters: [{ name: 'PDF Document', extensions: ['pdf'] }],
})
@@ -285,161 +362,47 @@ app.whenReady().then(async () => {
}
})
- // ─── Image Blind Watermark IPC ────────────────────────────────────────────
-
- const BWM_EXE = process.platform === 'win32' ? 'bwm_helper.exe' : 'bwm_helper'
-
- /** Find the pre-built standalone executable bundled with the app (preferred). */
- function findBundledExe(): string | null {
- const p = is.dev
- ? join(app.getAppPath(), 'resources', 'bin', BWM_EXE)
- : join(process.resourcesPath, 'bin', BWM_EXE)
- return existsSync(p) ? p : null
- }
-
- /** Try Python executables in PATH (developer fallback when exe not yet built).
- * On Windows, resolves to the absolute path via `where` to avoid PATH lookup
- * issues when Electron is launched outside a terminal. */
- async function findPythonExe(): Promise {
- if (process.platform === 'win32') {
- for (const cmd of ['python', 'python3', 'py']) {
- const resolved = await new Promise((resolve) => {
- const c = spawn('where', [cmd], { windowsHide: true, timeout: 4000, shell: true })
- let out = ''
- c.stdout?.on('data', (d: Buffer) => { out += d.toString() })
- c.on('close', (code) => {
- if (code !== 0) return resolve(null)
- const line = out.trim().split('\n')[0]?.trim()
- resolve(line && line.length > 0 ? line : null)
- })
- c.on('error', () => resolve(null))
- })
- if (resolved) return resolved
- }
- return null
- }
- for (const cmd of ['python3', 'python']) {
- const ok = await new Promise((resolve) => {
- const c = spawn(cmd, ['--version'], { timeout: 4000 })
- c.on('close', (code) => resolve(code === 0))
- c.on('error', () => resolve(false))
- })
- if (ok) return cmd
- }
- return null
- }
-
- type BwmRunner = { mode: 'exe'; exePath: string } | { mode: 'python'; python: string }
-
- /** Resolve the best available runner: bundled exe first, then system Python. */
- async function getRunner(): Promise {
- const exePath = findBundledExe()
- if (exePath) return { mode: 'exe', exePath }
- const python = await findPythonExe()
- return python ? { mode: 'python', python } : null
- }
-
- /** Path to bwm_helper.py (used only in Python fallback mode). */
- function bwmScriptPath(): string {
- return is.dev
- ? join(app.getAppPath(), 'blind_watermark', 'bwm_helper.py')
- : join(process.resourcesPath, 'bwm_helper.py')
- }
-
- /**
- * Run bwm_helper with options passed via JSON stdin (avoids Windows Unicode
- * command-line encoding issues). Parses last JSON stdout line.
- */
- function runBwm(runner: BwmRunner, opts: Record): Promise> {
- return new Promise((resolve) => {
- const spawnEnv = { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
- const child =
- runner.mode === 'exe'
- ? spawn(runner.exePath, ['--json-stdin'], { windowsHide: true, timeout: 120_000, env: spawnEnv })
- : spawn(runner.python, [bwmScriptPath(), '--json-stdin'], {
- windowsHide: true,
- timeout: 120_000,
- env: spawnEnv,
- cwd: is.dev ? join(app.getAppPath(), 'blind_watermark') : process.resourcesPath,
- })
-
- let stdout = ''
- let stderr = ''
- child.stdout?.on('data', (d: Buffer) => {
- stdout += d.toString('utf8')
- })
- child.stderr?.on('data', (d: Buffer) => {
- stderr += d.toString('utf8')
- })
- child.on('close', (code) => {
- const lines = stdout.trim().split('\n').filter(Boolean)
- const lastLine = lines[lines.length - 1] ?? ''
- try {
- resolve(JSON.parse(lastLine))
- } catch {
- resolve({
- ok: false,
- error: lastLine || stderr.trim() || `Process exited with code ${code}`,
- })
- }
- })
- child.on('error', (err) => {
- resolve({ ok: false, error: err.message })
- })
-
- // Write opts as UTF-8 JSON to stdin then close
- const payload = Buffer.from(JSON.stringify(opts), 'utf-8')
- child.stdin?.write(payload)
- child.stdin?.end()
- })
- }
-
- // Detect available engine (bundled exe takes priority over system Python)
ipcMain.handle('image-wm:checkPython', async () => {
+ const modelsDir = findBundledModelsDir()
const exePath = findBundledExe()
if (exePath) {
- const result = await runBwm({ mode: 'exe', exePath }, { mode: 'check' })
+ const result = await runBwm({ mode: 'exe', exePath }, { mode: 'check', models_dir: modelsDir })
return { ...result, mode: 'exe', python: null }
}
const python = await findPythonExe()
- if (!python) {
- return { ok: false, mode: 'python', python: null, error: 'no-runner' }
- }
- const result = await runBwm({ mode: 'python', python }, { mode: 'check' })
+ if (!python) return { ok: false, mode: 'python', python: null, error: 'no-runner' }
+ const result = await runBwm({ mode: 'python', python }, { mode: 'check', models_dir: modelsDir })
return { ...result, mode: 'python', python }
})
- // Open image file dialog
ipcMain.handle('image-wm:openImage', async () => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return null
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
- title: '选择源图片',
+ title: 'Open image',
properties: ['openFile'],
filters: [
- { name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'tif'] },
- { name: '所有文件', extensions: ['*'] },
+ { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'tif'] },
+ { name: 'All files', extensions: ['*'] },
],
})
return canceled || filePaths.length === 0 ? null : filePaths[0]
})
- // Save image file dialog
ipcMain.handle('image-wm:saveImage', async () => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return null
const { canceled, filePath } = await dialog.showSaveDialog(win, {
- title: '保存含水印图片',
+ title: 'Save watermarked image',
defaultPath: 'watermarked.png',
- filters: [{ name: 'PNG 图片', extensions: ['png'] }],
+ filters: [{ name: 'PNG Image', extensions: ['png'] }],
})
return canceled || !filePath ? null : filePath
})
- // Embed watermark into image
ipcMain.handle(
'image-wm:embed',
- async (_e, opts: { inputPath: string; outputPath: string; wmText: string; password: number; quality: string }) => {
+ async (_e, opts: { inputPath: string; outputPath: string; wmText: string; password: number; quality: string; engine: string }) => {
if (
!opts ||
!isValidPathInput(opts.inputPath) ||
@@ -451,13 +414,14 @@ app.whenReady().then(async () => {
opts.password < 0 ||
opts.password > 2_147_483_647 ||
!ALLOWED_QUALITY.has(opts.quality) ||
+ !ALLOWED_ENGINE.has(opts.engine) ||
!isAllowedImagePath(opts.inputPath) ||
!isAllowedImagePath(opts.outputPath)
) {
return { ok: false, error: 'Invalid image embed parameters' }
}
const runner = await getRunner()
- if (!runner) return { ok: false, error: '未找到可用的执行引擎' }
+ if (!runner) return { ok: false, error: 'No runnable image watermark backend available' }
return runBwm(runner, {
mode: 'embed',
input: opts.inputPath,
@@ -465,37 +429,43 @@ app.whenReady().then(async () => {
wm: opts.wmText,
password: opts.password,
quality: opts.quality,
+ engine: opts.engine,
+ models_dir: findBundledModelsDir(),
})
- }
+ },
)
- // Extract watermark from image
ipcMain.handle(
'image-wm:extract',
- async (_e, opts: { inputPath: string; password: number }) => {
+ async (_e, opts: { inputPath: string; password: number; quality: string; engine: string }) => {
if (
!opts ||
!isValidPathInput(opts.inputPath) ||
!Number.isInteger(opts.password) ||
opts.password < 0 ||
opts.password > 2_147_483_647 ||
+ !ALLOWED_QUALITY.has(opts.quality) ||
+ !ALLOWED_ENGINE.has(opts.engine) ||
!isAllowedImagePath(opts.inputPath)
) {
return { ok: false, error: 'Invalid image extract parameters' }
}
const runner = await getRunner()
- if (!runner) return { ok: false, error: '未找到可用的执行引擎' }
+ if (!runner) return { ok: false, error: 'No runnable image watermark backend available' }
return runBwm(runner, {
mode: 'extract',
input: opts.inputPath,
password: opts.password,
+ quality: opts.quality,
+ engine: opts.engine,
+ models_dir: findBundledModelsDir(),
})
- }
+ },
)
createWindow()
- app.on('activate', function () {
+ app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 5fcb8ae..627668a 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -1,6 +1,43 @@
import { ElectronAPI } from '@electron-toolkit/preload'
type SaveResult = { success: boolean; filePath?: string; error?: string }
+type RunnerMode = 'exe' | 'python'
+type WatermarkEngine = 'auto' | 'legacy' | 'neural'
+type WatermarkQuality = 'invisible' | 'balanced' | 'robust'
+
+type ImageWmCheckResult = {
+ ok: boolean
+ mode?: RunnerMode
+ python: string | null
+ hasLib?: boolean
+ version?: string
+ error?: string
+ neuralRuntimeAvailable?: boolean
+ neuralModelsAvailable?: boolean
+ neuralReady?: boolean
+ neuralModelVersion?: string | null
+}
+
+type ImageWmEmbedResult = {
+ ok: boolean
+ output?: string
+ quality?: string
+ error?: string
+ engineUsed?: WatermarkEngine | 'legacy'
+ fallbackUsed?: boolean
+ confidence?: number
+ diagnostics?: Record
+}
+
+type ImageWmExtractResult = {
+ ok: boolean
+ wm?: string
+ error?: string
+ engineUsed?: WatermarkEngine | 'legacy'
+ fallbackUsed?: boolean
+ confidence?: number
+ diagnostics?: Record
+}
interface AppAPI {
minimizeWindow: () => void
@@ -13,9 +50,7 @@ interface AppAPI {
storeGet: (key: string) => Promise
storeSet: (key: string, value: unknown) => Promise
storeGetAll: () => Promise>
-
- // Image blind watermark
- imageWmCheckPython: () => Promise<{ ok: boolean; mode?: 'exe' | 'python'; python: string | null; hasLib?: boolean; version?: string; error?: string }>
+ imageWmCheckPython: () => Promise
imageWmOpenImage: () => Promise
imageWmSaveImage: () => Promise
imageWmEmbed: (opts: {
@@ -23,12 +58,15 @@ interface AppAPI {
outputPath: string
wmText: string
password: number
- quality: string
- }) => Promise<{ ok: boolean; output?: string; quality?: string; error?: string }>
+ quality: WatermarkQuality
+ engine: WatermarkEngine
+ }) => Promise
imageWmExtract: (opts: {
inputPath: string
password: number
- }) => Promise<{ ok: boolean; wm?: string; error?: string }>
+ quality: WatermarkQuality
+ engine: WatermarkEngine
+ }) => Promise
}
declare global {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 9d35566..41fa633 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -2,6 +2,43 @@ import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
type SaveResult = { success: boolean; filePath?: string; error?: string }
+type RunnerMode = 'exe' | 'python'
+type WatermarkEngine = 'auto' | 'legacy' | 'neural'
+type WatermarkQuality = 'invisible' | 'balanced' | 'robust'
+
+type ImageWmCheckResult = {
+ ok: boolean
+ mode?: RunnerMode
+ python: string | null
+ hasLib?: boolean
+ version?: string
+ error?: string
+ neuralRuntimeAvailable?: boolean
+ neuralModelsAvailable?: boolean
+ neuralReady?: boolean
+ neuralModelVersion?: string | null
+}
+
+type ImageWmEmbedResult = {
+ ok: boolean
+ output?: string
+ quality?: string
+ error?: string
+ engineUsed?: WatermarkEngine | 'legacy'
+ fallbackUsed?: boolean
+ confidence?: number
+ diagnostics?: Record
+}
+
+type ImageWmExtractResult = {
+ ok: boolean
+ wm?: string
+ error?: string
+ engineUsed?: WatermarkEngine | 'legacy'
+ fallbackUsed?: boolean
+ confidence?: number
+ diagnostics?: Record
+}
const api = {
minimizeWindow: () => ipcRenderer.send('window:minimize'),
@@ -15,30 +52,26 @@ const api = {
ipcRenderer.invoke('dialog:exportPDF', htmlB64, defaultName),
readClipboard: (): Promise => ipcRenderer.invoke('clipboard:read'),
storeGet: (key: string): Promise => ipcRenderer.invoke('store:get', key),
- storeSet: (key: string, value: unknown): Promise =>
- ipcRenderer.invoke('store:set', key, value),
+ storeSet: (key: string, value: unknown): Promise => ipcRenderer.invoke('store:set', key, value),
storeGetAll: (): Promise> => ipcRenderer.invoke('store:getAll'),
- // ── Image blind watermark ──────────────────────────────────────────────────
- imageWmCheckPython: (): Promise<{ ok: boolean; mode?: 'exe' | 'python'; python: string | null; hasLib?: boolean; version?: string; error?: string }> =>
- ipcRenderer.invoke('image-wm:checkPython'),
- imageWmOpenImage: (): Promise =>
- ipcRenderer.invoke('image-wm:openImage'),
- imageWmSaveImage: (): Promise =>
- ipcRenderer.invoke('image-wm:saveImage'),
+ imageWmCheckPython: (): Promise => ipcRenderer.invoke('image-wm:checkPython'),
+ imageWmOpenImage: (): Promise => ipcRenderer.invoke('image-wm:openImage'),
+ imageWmSaveImage: (): Promise => ipcRenderer.invoke('image-wm:saveImage'),
imageWmEmbed: (opts: {
inputPath: string
outputPath: string
wmText: string
password: number
- quality: string
- }): Promise<{ ok: boolean; output?: string; quality?: string; error?: string }> =>
- ipcRenderer.invoke('image-wm:embed', opts),
+ quality: WatermarkQuality
+ engine: WatermarkEngine
+ }): Promise => ipcRenderer.invoke('image-wm:embed', opts),
imageWmExtract: (opts: {
inputPath: string
password: number
- }): Promise<{ ok: boolean; wm?: string; error?: string }> =>
- ipcRenderer.invoke('image-wm:extract', opts),
+ quality: WatermarkQuality
+ engine: WatermarkEngine
+ }): Promise => ipcRenderer.invoke('image-wm:extract', opts),
}
if (process.contextIsolated) {
diff --git a/src/renderer/src/components/ImageWatermarkPanel.tsx b/src/renderer/src/components/ImageWatermarkPanel.tsx
index 4f05b96..4fb7978 100644
--- a/src/renderer/src/components/ImageWatermarkPanel.tsx
+++ b/src/renderer/src/components/ImageWatermarkPanel.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
FolderOpen,
@@ -14,16 +14,18 @@ import {
CheckCircle,
} from '@phosphor-icons/react'
-// ─── Types ────────────────────────────────────────────────────────────────────
-
type PyStatus = 'idle' | 'checking' | 'ok' | 'no-python' | 'no-lib'
+type WatermarkQuality = 'invisible' | 'balanced' | 'robust'
+type ImageEngine = 'auto' | 'legacy' | 'neural'
interface PanelStatus {
kind: 'ok' | 'error' | 'warn'
message: string
}
-// ─── Shared small helpers ────────────────────────────────────────────────────
+interface DiagnosticsRecord {
+ [key: string]: unknown
+}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
@@ -35,19 +37,17 @@ function CopyButton({ text }: { text: string }) {
return (
)
}
function Label({ children }: { children: React.ReactNode }) {
- return (
- {children}
- )
+ return {children}
}
function StatusBadge({ kind, message }: PanelStatus) {
@@ -89,13 +89,7 @@ function ActionButton({