diff --git a/blind_watermark/mlwm/__init__.py b/blind_watermark/mlwm/__init__.py index 6b395dc..723ef21 100644 --- a/blind_watermark/mlwm/__init__.py +++ b/blind_watermark/mlwm/__init__.py @@ -2,16 +2,24 @@ from .codec import ( ENCODED_BYTES, + KEYED_PROTOCOL, PAYLOAD_BITS, MAX_TEXT_BYTES, decode_payload_bits, + decode_payload_logits, encode_text_payload, + key_payload_bits, + unkey_payload_bits, ) __all__ = [ 'ENCODED_BYTES', + 'KEYED_PROTOCOL', 'PAYLOAD_BITS', 'MAX_TEXT_BYTES', 'decode_payload_bits', + 'decode_payload_logits', 'encode_text_payload', + 'key_payload_bits', + 'unkey_payload_bits', ] diff --git a/blind_watermark/mlwm/codec.py b/blind_watermark/mlwm/codec.py index ae59808..61a1f5c 100644 --- a/blind_watermark/mlwm/codec.py +++ b/blind_watermark/mlwm/codec.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import struct import zlib from dataclasses import dataclass from typing import Any @@ -15,6 +17,7 @@ ENCODED_BYTES = FRAME_BYTES + RS_NSYM PAYLOAD_BITS = ENCODED_BYTES * 8 WHITENING_SEED = 0x4d4c574d +KEYED_PROTOCOL = 'keyed-v2' @dataclass @@ -25,6 +28,8 @@ class PayloadEnvelope: encoded: bytes bits: np.ndarray flags: int = 0 + protocol: str = KEYED_PROTOCOL + password_protected: bool = False def _whitening_mask() -> np.ndarray: @@ -35,6 +40,25 @@ def _whitening_mask() -> np.ndarray: _PAYLOAD_WHITENING_MASK = _whitening_mask() +def _normalize_password(password: int | None) -> int | None: + if password is None: + return None + return int(password) & 0xffffffff + + +def _password_mask(password: int | None) -> np.ndarray: + normalized = _normalize_password(password) + if normalized is None: + return np.zeros(PAYLOAD_BITS, dtype=np.float32) + seed = b'LuminCrypt-MLWM-keyed-v2' + struct.pack('>I', normalized) + stream = bytearray() + counter = 0 + while len(stream) < ENCODED_BYTES: + stream.extend(hashlib.sha256(seed + struct.pack('>I', counter)).digest()) + counter += 1 + return bytes_to_bits(bytes(stream[:ENCODED_BYTES])) + + def _rs_codec() -> reedsolo.RSCodec: return reedsolo.RSCodec(RS_NSYM) @@ -72,6 +96,27 @@ def unwhiten_payload_bits(bits: np.ndarray | list[float] | list[int]) -> np.ndar return whiten_payload_bits(bits) +def key_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> 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 - _password_mask(password)).astype(np.float32) + + +def unkey_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> np.ndarray: + return key_payload_bits(bits, password) + + +def unkey_payload_logits(logits: np.ndarray, password: int | None = None) -> np.ndarray: + arr = np.asarray(logits, dtype=np.float32).reshape(-1) + if arr.size != PAYLOAD_BITS: + raise ValueError(f'expected {PAYLOAD_BITS} logits, got {arr.size}') + mask = _password_mask(password) + signs = np.where(mask >= 0.5, -1.0, 1.0).astype(np.float32) + return arr * signs + + 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') @@ -96,17 +141,19 @@ def encode_frame(frame: bytes) -> bytes: return bytes(_rs_codec().encode(frame)) -def encode_text_payload(text: str, *, flags: int = 0) -> PayloadEnvelope: +def encode_text_payload(text: str, password: int | None = None, *, flags: int = 0) -> PayloadEnvelope: payload_bytes = text.encode('utf-8') frame = build_frame(payload_bytes, flags=flags) encoded = encode_frame(frame) + whitened = whiten_payload_bits(bytes_to_bits(encoded)) return PayloadEnvelope( text=text, text_bytes=payload_bytes, frame=frame, encoded=encoded, - bits=whiten_payload_bits(bytes_to_bits(encoded)), + bits=key_payload_bits(whitened, password), flags=flags, + password_protected=password is not None, ) @@ -135,22 +182,28 @@ def decode_frame(encoded_bytes: bytes) -> dict[str, Any]: } -def decode_payload_bits(bits: np.ndarray | list[float] | list[int]) -> dict[str, Any]: - raw_bits = unwhiten_payload_bits(bits) +def decode_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> dict[str, Any]: + unkeyed = unkey_payload_bits(bits, password) + raw_bits = unwhiten_payload_bits(unkeyed) encoded = bits_to_bytes(raw_bits) result = decode_frame(encoded) result['encoded'] = encoded result['rawBits'] = raw_bits + result['protocol'] = KEYED_PROTOCOL if password is not None else 'unkeyed-v1' + result['passwordProtected'] = password is not None return result -def decode_payload_logits(logits: np.ndarray) -> dict[str, Any]: +def decode_payload_logits(logits: np.ndarray, password: int | None = None) -> 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)) + decoded_logits = unkey_payload_logits(logits, password) + probs = 1.0 / (1.0 + np.exp(-decoded_logits)) bits = (probs >= 0.5).astype(np.float32) - decoded = decode_payload_bits(bits) + decoded = decode_payload_bits(bits, None) decoded['probabilities'] = probs decoded['bitConfidence'] = float(np.mean(np.maximum(probs, 1.0 - probs))) + decoded['protocol'] = KEYED_PROTOCOL if password is not None else 'unkeyed-v1' + decoded['passwordProtected'] = password is not None return decoded diff --git a/blind_watermark/mlwm/infer.py b/blind_watermark/mlwm/infer.py index b265b48..38c983a 100644 --- a/blind_watermark/mlwm/infer.py +++ b/blind_watermark/mlwm/infer.py @@ -146,6 +146,7 @@ def neural_decode_views( *, models_dir: str | None = None, use_cuda: bool = False, + password: int | None = None, ) -> dict[str, Any]: status = probe_runtime(models_dir) if not status.ready: @@ -167,7 +168,7 @@ def neural_decode_views( weighted_logits += logits * confidence total_weight += confidence try: - decoded = decode_payload_logits(logits) + decoded = decode_payload_logits(logits, password=password) decoded['confidence'] = confidence decoded['attemptIndex'] = index decoded['strategy'] = 'single-view' @@ -180,7 +181,7 @@ def neural_decode_views( raise NeuralRuntimeUnavailable('decoder produced no usable confidence scores') aggregated_logits = weighted_logits / total_weight - decoded = decode_payload_logits(aggregated_logits) + decoded = decode_payload_logits(aggregated_logits, password=password) 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' diff --git a/blind_watermark/rwm_engine.py b/blind_watermark/rwm_engine.py index f56c745..55c5bdc 100644 --- a/blind_watermark/rwm_engine.py +++ b/blind_watermark/rwm_engine.py @@ -82,17 +82,23 @@ # v2 compat constants _V2_RS_NSYM = 20 _V2_REDUNDANCY = 3 -RWM_VERSION = '3.1.0' +RWM_VERSION = '3.2.0' NEURAL_MAX_TEXT_BYTES = 16 NEURAL_PROFILES = { + 'invisible': { + 'residual_strength': 0.35, + 'template_strength': 0.0, + 'template_peaks': 0, + 'sync_enabled': False, + }, 'balanced': { - 'residual_strength': 1.0, + 'residual_strength': 0.55, 'template_strength': 0.0, 'template_peaks': 0, 'sync_enabled': False, }, - 'aggressive': { - 'residual_strength': 1.35, + 'robust': { + 'residual_strength': 1.0, 'template_strength': 0.0, 'template_peaks': 0, 'sync_enabled': False, @@ -517,8 +523,8 @@ def _try_all_presets(gray_ch, preset_order=None): def _resolve_neural_profile(quality): - if quality == 'robust': - return 'aggressive' + if quality in NEURAL_PROFILES: + return quality return 'balanced' @@ -600,7 +606,7 @@ def _neural_embed_impl(img, text, password=1, quality='balanced', models_dir=Non 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) + payload = encode_text_payload(text, password=password) rgb = cv2.cvtColor(base, cv2.COLOR_BGR2RGB) try: encoded = neural_encode_residual(rgb, payload.bits, models_dir=models_dir, use_cuda=False) @@ -622,6 +628,9 @@ def _neural_embed_impl(img, text, password=1, quality='balanced', models_dir=Non diagnostics = { 'profile': profile_name, + 'protocol': payload.protocol, + 'passwordProtected': payload.password_protected, + 'visualStrength': profile['residual_strength'], 'payloadBytes': len(payload.text_bytes), 'modelVersion': encoded.get('modelVersion'), 'modelsDir': models_dir, @@ -658,7 +667,7 @@ def _neural_extract_impl(img, password=1, quality='balanced', models_dir=None): geo = {'angle': 0.0, 'scale': 1.0, 'confidence': 0.0, 'peaks': 0, 'syncEnabled': False} views = _build_neural_views(corrected) try: - decoded = neural_decode_views(views, models_dir=models_dir, use_cuda=False) + decoded = neural_decode_views(views, models_dir=models_dir, use_cuda=False, password=password) except NeuralRuntimeUnavailable: raise @@ -671,6 +680,9 @@ def _neural_extract_impl(img, password=1, quality='balanced', models_dir=None): 'confidence': confidence, 'diagnostics': { 'profile': profile_name, + 'protocol': decoded.get('protocol', 'keyed-v2'), + 'passwordProtected': bool(decoded.get('passwordProtected', True)), + 'visualStrength': profile['residual_strength'], 'bitConfidence': float(decoded.get('bitConfidence', 0.0)), 'decodeStrategy': decoded.get('strategy'), 'geometricCorrection': geo, diff --git a/blind_watermark/tests/test_mlwm_codec.py b/blind_watermark/tests/test_mlwm_codec.py index 443855d..f25b5b1 100644 --- a/blind_watermark/tests/test_mlwm_codec.py +++ b/blind_watermark/tests/test_mlwm_codec.py @@ -8,6 +8,7 @@ bits_to_bytes, bytes_to_bits, decode_payload_bits, + decode_payload_logits, encode_frame, encode_text_payload, unwhiten_payload_bits, @@ -22,6 +23,31 @@ def test_roundtrip_short_text(self): self.assertEqual(decoded['text'], 'TRACE-42') self.assertEqual(len(envelope.encoded), ENCODED_BYTES) + def test_password_protected_payload_roundtrip(self): + envelope = encode_text_payload('LOCKED-42', password=2468) + decoded = decode_payload_bits(envelope.bits, password=2468) + self.assertEqual(decoded['text'], 'LOCKED-42') + self.assertTrue(envelope.password_protected) + self.assertTrue(decoded['passwordProtected']) + + def test_wrong_password_rejects_payload(self): + envelope = encode_text_payload('LOCKED-42', password=2468) + with self.assertRaises(Exception): + decode_payload_bits(envelope.bits, password=2469) + + def test_different_passwords_produce_different_payload_bits(self): + a = encode_text_payload('LOCKED-42', password=2468) + b = encode_text_payload('LOCKED-42', password=2469) + self.assertFalse(np.array_equal(a.bits, b.bits)) + + def test_password_protected_logits_roundtrip(self): + envelope = encode_text_payload('LOGITS-42', password=2468) + logits = (envelope.bits * 2.0 - 1.0) * 8.0 + decoded = decode_payload_logits(logits, password=2468) + self.assertEqual(decoded['text'], 'LOGITS-42') + with self.assertRaises(Exception): + decode_payload_logits(logits, password=2469) + def test_reject_long_text(self): with self.assertRaises(ValueError): encode_text_payload('X' * (MAX_TEXT_BYTES + 1)) diff --git a/blind_watermark/tests/test_mlwm_runtime.py b/blind_watermark/tests/test_mlwm_runtime.py index f8c714d..21a6b53 100644 --- a/blind_watermark/tests/test_mlwm_runtime.py +++ b/blind_watermark/tests/test_mlwm_runtime.py @@ -9,6 +9,11 @@ def test_alpha1_neural_profiles_do_not_inject_untrained_sync_template(self): self.assertFalse(profile.get('sync_enabled', False)) self.assertEqual(profile.get('template_strength'), 0.0) + def test_neural_profiles_expose_three_visual_strengths(self): + self.assertEqual(set(rwm_engine.NEURAL_PROFILES.keys()), {'invisible', 'balanced', 'robust'}) + self.assertLess(rwm_engine.NEURAL_PROFILES['invisible']['residual_strength'], rwm_engine.NEURAL_PROFILES['balanced']['residual_strength']) + self.assertLess(rwm_engine.NEURAL_PROFILES['balanced']['residual_strength'], rwm_engine.NEURAL_PROFILES['robust']['residual_strength']) + if __name__ == '__main__': unittest.main() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 421550a..1ab6511 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import { useClipboard } from './hooks/useClipboard' import { useSettings } from './contexts/SettingsContext' import { useBatch } from './hooks/useBatch' import { cleanAll } from './core/cleaner' +import { useI18n } from './i18n' const CompareView = lazy(() => import('./components/CompareView')) const SettingsPage = lazy(() => import('./components/SettingsPage')) @@ -30,6 +31,7 @@ function App(): React.JSX.Element { const [cleaned, setCleaned] = useState(null) const [bgImageUrl, setBgImageUrl] = useState(null) const [showBgPanel, setShowBgPanel] = useState(false) + const { t } = useI18n() const { settings, updateSettings } = useSettings() const batchHook = useBatch() @@ -152,7 +154,7 @@ function App(): React.JSX.Element { className="cosmic-btn flex items-center gap-1.5 text-xs px-3 py-2 rounded-xl cursor-pointer no-drag" > - 清理 + {t('common.clean')} )} @@ -164,7 +166,7 @@ function App(): React.JSX.Element { className="flex items-center gap-1.5 text-xs text-zinc-600 hover:text-cyan-300 transition-colors duration-300 cursor-pointer no-drag" > - 重置 + {t('common.reset')} )} @@ -210,11 +212,9 @@ function App(): React.JSX.Element {

- 粘贴文本,按{' '} - Ctrl+↵ - {' '}开始扫描 + {t('detect.empty', { shortcut: 'Ctrl+Enter' })}

-

检测零宽字符、同形字、BiDi 控制码、Tags 区块等

+

{t('detect.emptyHint')}

)} @@ -236,7 +236,7 @@ function App(): React.JSX.Element {
-

正在分析字符

+

{t('detect.analyzing')}

SCANNING UNICODE STREAM...

@@ -254,8 +254,8 @@ function App(): React.JSX.Element {
-

扫描失败

-

{errorMessage || '未知错误,请重试'}

+

{t('detect.error')}

+

{errorMessage || t('detect.unknownError')}

- 重置 + {t('common.reset')} )} @@ -280,10 +280,10 @@ function App(): React.JSX.Element {
{([ - { id: 'highlight' as const, label: '标注视图' }, + { id: 'highlight' as const, label: t('detect.highlight') }, ...(cleaned ? [ - { id: 'cleaned' as const, label: '净化文本' }, - { id: 'compare' as const, label: '对比' }, + { id: 'cleaned' as const, label: t('detect.cleaned') }, + { id: 'compare' as const, label: t('detect.compare') }, ] : []), ]).map(({ id, label }) => (
diff --git a/src/renderer/src/components/BackgroundSwitcher.tsx b/src/renderer/src/components/BackgroundSwitcher.tsx index 1f92a6a..843b270 100644 --- a/src/renderer/src/components/BackgroundSwitcher.tsx +++ b/src/renderer/src/components/BackgroundSwitcher.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X, ImagesSquare, UploadSimple, Trash } from '@phosphor-icons/react' +import { useI18n } from '../i18n' interface BackgroundSwitcherProps { open: boolean @@ -15,6 +16,7 @@ export default function BackgroundSwitcher({ onSelect, onClose, }: BackgroundSwitcherProps) { + const { t } = useI18n() const fileInputRef = useRef(null) const handleFileChange = (e: React.ChangeEvent) => { @@ -78,7 +80,7 @@ export default function BackgroundSwitcher({
- 背景图片 + {t('background.title')} {/* Remove button (only when custom bg is set) */} @@ -149,12 +151,12 @@ export default function BackgroundSwitcher({ " > - 恢复动态背景 + {t('background.restore')} )}

- 支持 PNG · JPG · WebP · GIF + {t('background.supports')}

diff --git a/src/renderer/src/components/BatchMode.tsx b/src/renderer/src/components/BatchMode.tsx index ea2c06e..bc9c7c1 100644 --- a/src/renderer/src/components/BatchMode.tsx +++ b/src/renderer/src/components/BatchMode.tsx @@ -16,10 +16,12 @@ import { } from '@phosphor-icons/react' import { BatchItem, useBatch } from '../hooks/useBatch' import StatsPanel from './StatsPanel' +import { useI18n } from '../i18n' type BatchHook = ReturnType export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { + const { t } = useI18n() const { items, scanning, @@ -77,9 +79,9 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {

- 批量检测 + {t('batch.title')}

-

同时检测多个文件或多段文本

+

{t('batch.subtitle')}

{items.length > 0 && ( @@ -87,10 +89,10 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { {doneCount > 0 && (
- {doneCount}/{items.length} 已扫描 + {t('batch.scanned', { done: doneCount, total: items.length })} - 平均风险{' '} + {t('batch.avgRisk')}{' '} - 全部清理 + {t('batch.cleanAll')} )} {/* Scan selected / scan all */} @@ -119,7 +121,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { className="flex items-center gap-1.5 text-xs text-white bg-[#3b7cd4] hover:bg-[#4d8de0] disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 rounded-lg transition-all cursor-pointer no-drag font-medium" > - {scanning ? '扫描中…' : `扫描已选 (${selectedCount})`} + {scanning ? t('common.scanning') : t('batch.scanSelected', { count: selectedCount })} ) : ( )}
)} @@ -148,8 +150,8 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { {/* Mode toggle */}
{([ - { id: 'file' as const, label: '文件', icon: }, - { id: 'text' as const, label: '文本', icon: }, + { id: 'file' as const, label: t('batch.file'), icon: }, + { id: 'text' as const, label: t('batch.text'), icon: }, ]).map(({ id, label, icon }) => (
-

拖入多个文件

-

点击或拖放 · .txt .md .docx .pdf

+

{t('batch.dropFiles')}

+

{t('batch.dropHint')}

setSegmentText(e.target.value)} - placeholder={`粘贴多段文本,用\n---\n分隔各段`} + placeholder={t('batch.segmentPlaceholder')} className="flex-1 bg-white/[0.03] rounded-xl border border-white/[0.07] text-sm text-zinc-200 leading-relaxed font-['Geist_Mono',monospace] resize-none px-4 py-3 focus:outline-none focus:border-[#3b7cd4]/40 placeholder:text-zinc-600" spellCheck={false} /> @@ -210,7 +212,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { disabled={!segmentText.trim()} className="flex items-center justify-center gap-1.5 text-xs text-zinc-200 bg-white/[0.06] hover:bg-white/[0.09] disabled:opacity-40 disabled:cursor-not-allowed border border-white/[0.08] px-3 py-2 rounded-lg transition-all cursor-pointer no-drag" > - 添加文本段落 + {t('batch.addSegments')}
)} @@ -223,7 +225,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
-

拖入文件或粘贴文本段落开始批量检测

+

{t('batch.empty')}

) : (
@@ -240,10 +242,10 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) { ) : ( )} - {allSelected ? '取消全选' : '全选'} + {allSelected ? t('batch.unselectAll') : t('batch.selectAll')} {selectedCount > 0 && ( - 已选 {selectedCount} 项 + {t('batch.selected', { count: selectedCount })} )}
@@ -283,10 +285,18 @@ function BatchItemCard({ onRemove: () => void onSelect: () => void }) { + const { t } = useI18n() const score = item.result?.riskScore ?? 0 const scoreColor = score < 30 ? '#10b981' : score < 65 ? '#f59e0b' : '#ef4444' - const riskLabel = score < 30 ? '低风险' : score < 65 ? '中等' : '高风险' - const actionLabel = score === 0 ? '无异常' : score < 30 ? '可忽略' : score < 65 ? '建议清理' : '建议重写' + const riskLabel = score < 30 ? t('stats.low') : score < 65 ? t('stats.medium') : t('stats.high') + const actionLabel = + score === 0 + ? t('risk.none') + : score < 30 + ? t('stats.action.ignore') + : score < 65 + ? t('stats.action.clean') + : t('stats.action.rewrite') return ( {item.name}

{item.status === 'done' && item.result && (

- {item.result.totalChars.toLocaleString()} 字符 · 发现 {item.result.suspiciousCount} 处 + {t('batch.itemSummary', { + chars: item.result.totalChars.toLocaleString(), + count: item.result.suspiciousCount, + })}

)} {item.status === 'error' && ( @@ -381,7 +394,7 @@ function BatchItemCard({ @@ -389,7 +402,7 @@ function BatchItemCard({ @@ -410,7 +423,7 @@ function BatchItemCard({ {item.cleanedText && (
-

净化结果

+

{t('batch.cleanResult')}