diff --git a/locales/de.yml b/locales/de.yml index 09a551a..f0cca84 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "Spektrale Flachheit %{value} deutet auf synthetisches Au signal_wav_info_tool: "WAV INFO %{key} stimmt mit KI-Tool überein: %{value}" signal_wav_tts_heuristic: "Audioeigenschaften deuten auf TTS hin: Mono %{rate}Hz %{bits}bit" signal_video_frame_watermark: "Wasserzeichen-Indikatoren in Videobild erkannt (bei %{frame}): %{indicators}" +signal_visible_watermark_badge: "Sichtbares KI-Wasserzeichen-Badge in %{corner} Ecke erkannt (%{indicators})" +signal_visible_watermark_generic: "Sichtbare Textüberlagerung in %{corner} Ecke erkannt (möglicherweise Wasserzeichen)" diff --git a/locales/en.yml b/locales/en.yml index 918ee10..9b8f020 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "Spectral flatness %{value} suggests synthetic audio (nat signal_wav_info_tool: "WAV INFO %{key} matches AI tool: %{value}" signal_wav_tts_heuristic: "Audio characteristics suggest TTS: mono %{rate}Hz %{bits}bit" signal_video_frame_watermark: "Video frame watermark indicators detected (at %{frame}): %{indicators}" +signal_visible_watermark_badge: "Visible AI watermark badge detected in %{corner} corner (%{indicators})" +signal_visible_watermark_generic: "Visible text overlay detected in %{corner} corner (possible watermark)" diff --git a/locales/es.yml b/locales/es.yml index 9d08fdf..3732fb3 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "Planitud espectral %{value} sugiere audio sintético (el signal_wav_info_tool: "WAV INFO %{key} coincide con herramienta de IA: %{value}" signal_wav_tts_heuristic: "Características de audio sugieren TTS: mono %{rate}Hz %{bits}bit" signal_video_frame_watermark: "Indicadores de marca de agua detectados en fotograma de video (en %{frame}): %{indicators}" +signal_visible_watermark_badge: "Insignia de marca de agua de IA visible detectada en esquina %{corner} (%{indicators})" +signal_visible_watermark_generic: "Superposición de texto visible detectada en esquina %{corner} (posible marca de agua)" diff --git a/locales/hi.yml b/locales/hi.yml index 625475e..527d5da 100644 --- a/locales/hi.yml +++ b/locales/hi.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "स्पेक्ट्रल समतलता %{valu signal_wav_info_tool: "WAV INFO %{key} AI टूल से मेल खाता है: %{value}" signal_wav_tts_heuristic: "ऑडियो विशेषताएँ TTS का संकेत देती हैं: मोनो %{rate}Hz %{bits}bit" signal_video_frame_watermark: "वीडियो फ़्रेम वॉटरमार्क संकेतक पाए गए (%{frame} पर): %{indicators}" +signal_visible_watermark_badge: "%{corner} कोने में दृश्य AI वॉटरमार्क बैज पाया गया (%{indicators})" +signal_visible_watermark_generic: "%{corner} कोने में दृश्य टेक्स्ट ओवरले पाया गया (वॉटरमार्क हो सकता है)" diff --git a/locales/ja.yml b/locales/ja.yml index 4694de5..6c10dd9 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "スペクトル平坦度 %{value} は合成音声を示 signal_wav_info_tool: "WAV INFO %{key} がAIツールに一致:%{value}" signal_wav_tts_heuristic: "音声特性がTTSを示唆:モノラル %{rate}Hz %{bits}bit" signal_video_frame_watermark: "動画フレームの電子透かし指標を検出(%{frame}時点):%{indicators}" +signal_visible_watermark_badge: "%{corner}コーナーに可視AIウォーターマークバッジを検出(%{indicators})" +signal_visible_watermark_generic: "%{corner}コーナーに可視テキストオーバーレイを検出(ウォーターマークの可能性)" diff --git a/locales/ko.yml b/locales/ko.yml index bda6a47..5282115 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "스펙트럼 평탄도 %{value}는 합성 오디오를 signal_wav_info_tool: "WAV INFO %{key}이(가) AI 도구와 일치: %{value}" signal_wav_tts_heuristic: "오디오 특성이 TTS를 시사: 모노 %{rate}Hz %{bits}bit" signal_video_frame_watermark: "비디오 프레임 워터마크 지표 감지 (%{frame} 위치): %{indicators}" +signal_visible_watermark_badge: "%{corner} 모서리에서 가시적 AI 워터마크 배지 감지 (%{indicators})" +signal_visible_watermark_generic: "%{corner} 모서리에서 가시적 텍스트 오버레이 감지 (워터마크 가능성)" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 02d8a8c..b997ecb 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -66,3 +66,5 @@ signal_audio_flatness: "频谱平坦度 %{value} 表明为合成音频(自然 signal_wav_info_tool: "WAV INFO %{key} 匹配 AI 工具:%{value}" signal_wav_tts_heuristic: "音频特征表明为 TTS:单声道 %{rate}Hz %{bits}bit" signal_video_frame_watermark: "视频帧水印指标检测到(位于 %{frame}):%{indicators}" +signal_visible_watermark_badge: "在%{corner}角检测到可见AI水印标识(%{indicators})" +signal_visible_watermark_generic: "在%{corner}角检测到可见文字覆盖层(可能为水印)" diff --git a/src/detector/mod.rs b/src/detector/mod.rs index 71e2f2b..253d21f 100644 --- a/src/detector/mod.rs +++ b/src/detector/mod.rs @@ -5,6 +5,7 @@ pub mod filename; pub mod id3_metadata; pub mod mp4_metadata; pub mod png_text; +pub mod visible_watermark; pub mod watermark; pub mod wav_metadata; pub mod xmp; @@ -351,6 +352,15 @@ pub fn run_all_detectors(path: &Path, deep: bool) -> FileReport { } } } + // Visible watermark detection (corner badge analysis) + match visible_watermark::detect(path) { + Ok(sigs) => signals.extend(sigs), + Err(e) => { + if std::env::var("AIC_DEBUG").is_ok() { + eprintln!(" [debug] Visible watermark: {}", e); + } + } + } } } diff --git a/src/detector/visible_watermark.rs b/src/detector/visible_watermark.rs new file mode 100644 index 0000000..f905f1f --- /dev/null +++ b/src/detector/visible_watermark.rs @@ -0,0 +1,609 @@ +use anyhow::{Context, Result}; +use std::path::Path; + +use super::{Confidence, Signal, SignalBuilder, SignalSource}; + +/// Maximum image dimension — higher than invisible watermark detector since we need spatial detail. +const MAX_DIM: u32 = 2048; +/// Images smaller than this are unlikely to have meaningful visible watermarks. +const MIN_DIM: u32 = 200; + +// Corner region sizing (fraction of image dimensions) +const CORNER_WIDTH_FRAC: f64 = 0.22; +const CORNER_HEIGHT_FRAC: f64 = 0.12; +const CORNER_MIN_WIDTH_PX: u32 = 80; +const CORNER_MIN_HEIGHT_PX: u32 = 50; + +// Text detection thresholds +/// Bright pixels must be this many luminance units above the corner region mean. +const BRIGHT_DELTA: f64 = 35.0; +/// Minimum valid horizontal run length for text-like patterns. +const MIN_RUN_LENGTH: u32 = 3; +/// Maximum valid horizontal run length for text-like patterns. +const MAX_RUN_LENGTH: u32 = 60; +/// A row needs at least this many valid bright runs to count as a "text row". +const MIN_RUNS_PER_ROW: u32 = 2; +/// Minimum fraction of rows within the text bounding box that must be text rows. +const MIN_TEXT_ROWS_FRACTION: f64 = 0.12; +/// Maximum bbox height as fraction of corner region height. Rejects non-compact matches. +const MAX_BBOX_HEIGHT_FRAC: f64 = 0.50; +/// Maximum gap (in rows) between consecutive text rows within a cluster. +const MAX_CLUSTER_GAP: u32 = 3; +/// Minimum bright pixel ratio within the text bounding box. +const MIN_BRIGHT_RATIO: f64 = 0.03; +/// Maximum bright pixel ratio within the text bounding box. +const MAX_BRIGHT_RATIO: f64 = 0.50; +/// Minimum number of text rows to consider a cluster valid. +const MIN_TEXT_ROW_COUNT: u32 = 3; +/// Minimum bounding box dimensions for a valid text cluster. +const MIN_BBOX_WIDTH: u32 = 30; +const MIN_BBOX_HEIGHT: u32 = 12; +/// Bimodal luminance check: minimum difference between cluster centers. +const BIMODAL_CENTER_DIFF: f64 = 50.0; + +/// Minimum indicators required to report a detection. +const MIN_INDICATORS: usize = 2; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Corner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +impl Corner { + fn name(&self) -> &'static str { + match self { + Corner::TopLeft => "top-left", + Corner::TopRight => "top-right", + Corner::BottomLeft => "bottom-left", + Corner::BottomRight => "bottom-right", + } + } + + /// Top-left and bottom-right are known positions for Chinese AI disclosure watermarks. + fn is_known_ai_position(&self) -> bool { + matches!(self, Corner::TopLeft | Corner::BottomRight) + } +} + +/// Bounding box of detected text cluster within a corner region. +struct TextBbox { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +struct TextCluster { + start_row: u32, + end_row: u32, +} + +struct TextAnalysis { + bright_ratio: f64, + text_row_fraction: f64, + is_bimodal: bool, + mean_lum: f64, +} + +/// Detect visible AI watermark badges in image corner regions. +/// +/// Looks for the characteristic pattern of Chinese AI regulatory watermarks: +/// bright text overlay in corners (e.g., "AI生成", "即梦AI"). +/// Uses a bottom-up approach: finds bright text clusters first, then validates. +pub fn detect(path: &Path) -> Result> { + let img = image::open(path).context("Failed to open image for visible watermark analysis")?; + let img = if img.width() > MAX_DIM || img.height() > MAX_DIM { + img.resize(MAX_DIM, MAX_DIM, image::imageops::FilterType::Lanczos3) + } else { + img + }; + + let (w, h) = (img.width(), img.height()); + if w < MIN_DIM || h < MIN_DIM { + return Ok(vec![]); + } + + let gray = img.to_luma8(); + let debug = std::env::var("AIC_DEBUG").is_ok(); + + // Corner region dimensions + let region_w = ((w as f64 * CORNER_WIDTH_FRAC) as u32) + .max(CORNER_MIN_WIDTH_PX) + .min(w / 2); + let region_h = ((h as f64 * CORNER_HEIGHT_FRAC) as u32) + .max(CORNER_MIN_HEIGHT_PX) + .min(h / 2); + + let corners = [ + Corner::TopLeft, + Corner::TopRight, + Corner::BottomLeft, + Corner::BottomRight, + ]; + + let mut signals = Vec::new(); + + for &corner in &corners { + let (ox, oy) = corner_offset(corner, w, h, region_w, region_h); + let region = extract_region(&gray, ox, oy, region_w, region_h); + + let region_mean = compute_mean(®ion); + let bright_threshold = (region_mean + BRIGHT_DELTA).min(255.0) as u8; + + // Step 1: Find text rows (rows with multiple bright pixel runs) + let text_rows = find_text_rows(®ion, region_w, region_h, bright_threshold); + + let total_text_rows = text_rows.iter().filter(|&&v| v).count() as u32; + if total_text_rows < MIN_TEXT_ROW_COUNT { + if debug { + eprintln!( + " [debug] Visible watermark {}: region_mean={:.1} bright_thr={} text_rows={}", + corner.name(), + region_mean, + bright_threshold, + total_text_rows + ); + } + continue; + } + + // Step 2: Split text rows into compact clusters (groups separated by gaps) + let clusters = find_text_clusters(&text_rows); + + for cluster in &clusters { + let cluster_h = cluster.end_row - cluster.start_row; + if cluster_h < MIN_BBOX_HEIGHT + || cluster_h as f64 > region_h as f64 * MAX_BBOX_HEIGHT_FRAC + { + continue; + } + + // Find bounding box of bright pixels within this cluster's row range + let bbox = match find_cluster_bbox( + ®ion, + region_w, + cluster.start_row, + cluster.end_row, + bright_threshold, + ) { + Some(b) => b, + None => continue, + }; + + if bbox.x1 - bbox.x0 < MIN_BBOX_WIDTH { + continue; + } + + // Step 3: Analyze text characteristics within bounding box + let analysis = analyze_text_cluster(®ion, region_w, &bbox, bright_threshold); + + let mut indicator_count = 0usize; + let mut indicators = Vec::new(); + + if analysis.bright_ratio >= MIN_BRIGHT_RATIO + && analysis.bright_ratio <= MAX_BRIGHT_RATIO + { + indicator_count += 1; + indicators.push(format!( + "bright pixel ratio {:.0}%", + analysis.bright_ratio * 100.0 + )); + } + + if analysis.text_row_fraction >= MIN_TEXT_ROWS_FRACTION { + indicator_count += 1; + indicators.push("horizontal text runs".to_string()); + } + + if analysis.is_bimodal { + indicator_count += 1; + indicators.push("bimodal luminance".to_string()); + } + + if debug { + eprintln!( + " [debug] Visible watermark {}: region_mean={:.1} bbox=({},{})..({},{}) bright_ratio={:.3} text_rows={:.3} bimodal={} indicators={}", + corner.name(), + region_mean, + bbox.x0 + ox, bbox.y0 + oy, bbox.x1 + ox, bbox.y1 + oy, + analysis.bright_ratio, + analysis.text_row_fraction, + analysis.is_bimodal, + indicator_count + ); + } + + if indicator_count < MIN_INDICATORS { + continue; + } + + let (confidence, msg_key) = if corner.is_known_ai_position() && indicator_count >= 3 { + (Confidence::Medium, "signal_visible_watermark_badge") + } else { + (Confidence::Low, "signal_visible_watermark_generic") + }; + + let indicators_str = indicators.join("; "); + + signals.push( + SignalBuilder::new(SignalSource::Watermark, confidence, msg_key) + .param("corner", corner.name()) + .param("indicators", &indicators_str) + .detail("corner", corner.name()) + .detail("region_mean_luminance", format!("{:.1}", analysis.mean_lum)) + .detail( + "bright_pixel_ratio", + format!("{:.3}", analysis.bright_ratio), + ) + .detail( + "text_row_fraction", + format!("{:.3}", analysis.text_row_fraction), + ) + .detail("indicator_count", indicator_count.to_string()) + .build(), + ); + + // One detection per corner is enough + break; + } + } + + Ok(signals) +} + +/// Compute the top-left origin of a corner region. +fn corner_offset(corner: Corner, w: u32, h: u32, rw: u32, rh: u32) -> (u32, u32) { + match corner { + Corner::TopLeft => (0, 0), + Corner::TopRight => (w.saturating_sub(rw), 0), + Corner::BottomLeft => (0, h.saturating_sub(rh)), + Corner::BottomRight => (w.saturating_sub(rw), h.saturating_sub(rh)), + } +} + +/// Extract a rectangular region of grayscale pixels. +fn extract_region(gray: &image::GrayImage, ox: u32, oy: u32, rw: u32, rh: u32) -> Vec { + let mut region = Vec::with_capacity((rw * rh) as usize); + for y in oy..oy + rh { + for x in ox..ox + rw { + region.push(gray.get_pixel(x, y).0[0]); + } + } + region +} + +/// Compute mean luminance of a region. +fn compute_mean(region: &[u8]) -> f64 { + if region.is_empty() { + return 128.0; + } + let sum: u64 = region.iter().map(|&v| v as u64).sum(); + sum as f64 / region.len() as f64 +} + +/// Identify which rows contain text-like bright pixel runs. +/// Returns a boolean per row. +fn find_text_rows(region: &[u8], rw: u32, rh: u32, bright_threshold: u8) -> Vec { + let mut text_rows = Vec::with_capacity(rh as usize); + for y in 0..rh { + let mut run_len = 0u32; + let mut runs_in_row = 0u32; + for x in 0..rw { + if region[(y * rw + x) as usize] >= bright_threshold { + run_len += 1; + } else { + if (MIN_RUN_LENGTH..=MAX_RUN_LENGTH).contains(&run_len) { + runs_in_row += 1; + } + run_len = 0; + } + } + // Check final run + if (MIN_RUN_LENGTH..=MAX_RUN_LENGTH).contains(&run_len) { + runs_in_row += 1; + } + text_rows.push(runs_in_row >= MIN_RUNS_PER_ROW); + } + text_rows +} + +/// Split text rows into compact clusters separated by gaps of non-text rows. +/// Each cluster is a contiguous (or near-contiguous) group of text rows. +fn find_text_clusters(text_rows: &[bool]) -> Vec { + let mut clusters = Vec::new(); + let mut start: Option = None; + let mut gap = 0u32; + + for (y, &is_text) in text_rows.iter().enumerate() { + let y = y as u32; + if is_text { + if start.is_none() { + start = Some(y); + } + gap = 0; + } else if let Some(s) = start { + gap += 1; + if gap > MAX_CLUSTER_GAP { + let end = y - gap; + if end > s + MIN_TEXT_ROW_COUNT { + clusters.push(TextCluster { + start_row: s, + end_row: end, + }); + } + start = None; + gap = 0; + } + } + } + + // Final cluster + if let Some(s) = start { + let end = text_rows.len() as u32 - gap; + if end > s + MIN_TEXT_ROW_COUNT { + clusters.push(TextCluster { + start_row: s, + end_row: end, + }); + } + } + + // Sort by size (largest first — most likely to be the real watermark) + clusters.sort_by(|a, b| { + let a_size = a.end_row - a.start_row; + let b_size = b.end_row - b.start_row; + b_size.cmp(&a_size) + }); + + clusters +} + +/// Find the bounding box of bright pixels within a row range. +fn find_cluster_bbox( + region: &[u8], + rw: u32, + start_row: u32, + end_row: u32, + bright_threshold: u8, +) -> Option { + let mut min_x = rw; + let mut max_x = 0u32; + + for y in start_row..end_row { + for x in 0..rw { + if region[(y * rw + x) as usize] >= bright_threshold { + min_x = min_x.min(x); + max_x = max_x.max(x); + } + } + } + + if max_x > min_x { + let pad_x = ((max_x - min_x) / 10).max(2); + let pad_y = ((end_row - start_row) / 10).max(1); + Some(TextBbox { + x0: min_x.saturating_sub(pad_x), + y0: start_row.saturating_sub(pad_y), + x1: (max_x + pad_x).min(rw), + y1: (end_row + pad_y).min(region.len() as u32 / rw), + }) + } else { + None + } +} + +/// Analyze text characteristics within the text cluster bounding box. +fn analyze_text_cluster( + region: &[u8], + rw: u32, + bbox: &TextBbox, + bright_threshold: u8, +) -> TextAnalysis { + let bw = bbox.x1 - bbox.x0; + let bh = bbox.y1 - bbox.y0; + + // Bright pixel ratio within bbox + let mut bright_count = 0u32; + let mut sum_lum = 0u64; + let total = bw * bh; + for y in bbox.y0..bbox.y1 { + for x in bbox.x0..bbox.x1 { + let v = region[(y * rw + x) as usize]; + sum_lum += v as u64; + if v >= bright_threshold { + bright_count += 1; + } + } + } + let bright_ratio = if total > 0 { + bright_count as f64 / total as f64 + } else { + 0.0 + }; + let mean_lum = if total > 0 { + sum_lum as f64 / total as f64 + } else { + 0.0 + }; + + // Text row fraction within bbox + let mut text_rows_in_bbox = 0u32; + for y in bbox.y0..bbox.y1 { + let mut run_len = 0u32; + let mut runs = 0u32; + for x in bbox.x0..bbox.x1 { + if region[(y * rw + x) as usize] >= bright_threshold { + run_len += 1; + } else { + if (MIN_RUN_LENGTH..=MAX_RUN_LENGTH).contains(&run_len) { + runs += 1; + } + run_len = 0; + } + } + if (MIN_RUN_LENGTH..=MAX_RUN_LENGTH).contains(&run_len) { + runs += 1; + } + if runs >= MIN_RUNS_PER_ROW { + text_rows_in_bbox += 1; + } + } + let text_row_fraction = if bh > 0 { + text_rows_in_bbox as f64 / bh as f64 + } else { + 0.0 + }; + + // Bimodal luminance check + let is_bimodal = check_bimodal(region, rw, bbox, bright_threshold); + + TextAnalysis { + bright_ratio, + text_row_fraction, + is_bimodal, + mean_lum, + } +} + +/// Check if the bounding box has a bimodal luminance distribution (background + text). +fn check_bimodal(region: &[u8], rw: u32, bbox: &TextBbox, threshold: u8) -> bool { + let mut dark_sum = 0u64; + let mut dark_count = 0u64; + let mut bright_sum = 0u64; + let mut bright_count = 0u64; + + for y in bbox.y0..bbox.y1 { + for x in bbox.x0..bbox.x1 { + let v = region[(y * rw + x) as usize] as u64; + if v >= threshold as u64 { + bright_sum += v; + bright_count += 1; + } else { + dark_sum += v; + dark_count += 1; + } + } + } + + if dark_count == 0 || bright_count == 0 { + return false; + } + + let dark_mean = dark_sum as f64 / dark_count as f64; + let bright_mean = bright_sum as f64 / bright_count as f64; + let total = (dark_count + bright_count) as f64; + let smaller = dark_count.min(bright_count) as f64; + + // Two clusters far apart, smaller cluster is 3-50% of total + (bright_mean - dark_mean) > BIMODAL_CENTER_DIFF + && smaller / total >= 0.03 + && smaller / total <= 0.50 +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{GrayImage, Luma}; + + /// Save image to a temp file with .png extension so image::open() can detect the format. + fn save_tmp_png(img: &GrayImage) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.png"); + img.save(&path).unwrap(); + dir + } + + /// Create a test image with bright "text" runs in a corner. + fn make_image_with_text_overlay( + width: u32, + height: u32, + bg_lum: u8, + text_lum: u8, + text_x: u32, + text_y: u32, + text_w: u32, + text_h: u32, + ) -> GrayImage { + let mut img = GrayImage::from_pixel(width, height, Luma([bg_lum])); + + // Simulate text runs (alternating bright/dark horizontal segments) + for y in text_y..text_y + text_h { + if y >= height { + break; + } + let mut x = text_x + 2; + while x + 5 < text_x + text_w && x + 5 < width { + // Bright run of ~4px + for dx in 0..4 { + img.put_pixel(x + dx, y, Luma([text_lum])); + } + x += 7; // Gap between runs + } + } + + img + } + + #[test] + fn test_detects_text_in_top_left() { + // 800x600 image, gray background, white text overlay in top-left + let img = make_image_with_text_overlay(800, 600, 100, 220, 10, 10, 100, 30); + let dir = save_tmp_png(&img); + + let signals = detect(&dir.path().join("test.png")).unwrap(); + assert!( + !signals.is_empty(), + "Expected visible watermark detection for text in top-left" + ); + let desc = &signals[0].description; + assert!( + desc.contains("top-left"), + "Expected top-left corner, got: {}", + desc + ); + } + + #[test] + fn test_detects_text_in_bottom_right() { + // Bright text in bottom-right corner + let img = make_image_with_text_overlay(800, 600, 80, 230, 690, 565, 100, 25); + let dir = save_tmp_png(&img); + + let signals = detect(&dir.path().join("test.png")).unwrap(); + assert!( + !signals.is_empty(), + "Expected visible watermark detection for text in bottom-right" + ); + } + + #[test] + fn test_no_detection_on_uniform_image() { + let img = GrayImage::from_pixel(800, 600, Luma([128])); + let dir = save_tmp_png(&img); + + let signals = detect(&dir.path().join("test.png")).unwrap(); + assert!( + signals.is_empty(), + "Uniform image should not trigger detection" + ); + } + + #[test] + fn test_no_detection_on_small_image() { + let img = GrayImage::from_pixel(100, 100, Luma([128])); + let dir = save_tmp_png(&img); + + let signals = detect(&dir.path().join("test.png")).unwrap(); + assert!(signals.is_empty(), "Small image should be skipped"); + } + + #[test] + fn test_mean_computation() { + let data = vec![100u8; 100]; + let mean = compute_mean(&data); + assert!((mean - 100.0).abs() < 0.01); + } +} diff --git a/src/detector/watermark.rs b/src/detector/watermark.rs index 4248f82..7acfe01 100644 --- a/src/detector/watermark.rs +++ b/src/detector/watermark.rs @@ -15,6 +15,12 @@ const MIN_INDICATORS: usize = 2; const NOISE_ASYMMETRY_THRESHOLD: f64 = 0.08; const BIT_AGREEMENT_THRESHOLD: f64 = 0.62; +// "Exceptionally strong" thresholds — when individual indicators far exceed their +// base thresholds, we upgrade confidence even with only 2/3 indicators firing. +const STRONG_BIT_AGREEMENT: f64 = 0.90; +const STRONG_ENERGY_SPREAD: f64 = 1.0; +const STRONG_NOISE_ASYMMETRY: f64 = 0.25; + pub fn detect(path: &Path) -> Result> { let img = image::open(path).context("Failed to open image for watermark analysis")?; let img = if img.width() > MAX_DIM || img.height() > MAX_DIM { @@ -33,6 +39,7 @@ pub fn detect(path: &Path) -> Result> { let debug = std::env::var("AIC_DEBUG").is_ok(); let mut indicators: Vec<&str> = Vec::new(); let mut details = Vec::new(); + let mut has_exceptionally_strong = false; let channels = extract_rgb_channels(&rgba, w, h); let cw = w - (w % 2); @@ -79,6 +86,9 @@ pub fn detect(path: &Path) -> Result> { details.push(("noise_asymmetry".to_string(), format!("{:.3}", asymmetry))); if asymmetry > NOISE_ASYMMETRY_THRESHOLD { indicators.push("channel noise asymmetry"); + if asymmetry > STRONG_NOISE_ASYMMETRY { + has_exceptionally_strong = true; + } } } @@ -135,6 +145,9 @@ pub fn detect(path: &Path) -> Result> { if best_agreement > BIT_AGREEMENT_THRESHOLD { indicators.push("cross-channel bit consistency"); details.push(("best_quant_step".to_string(), format!("{:.0}", best_q))); + if best_agreement > STRONG_BIT_AGREEMENT { + has_exceptionally_strong = true; + } } // Analysis 3: DWT residual energy ratio @@ -174,23 +187,32 @@ pub fn detect(path: &Path) -> Result> { } if ratio_spread > 0.25 { indicators.push("asymmetric DWT energy distribution"); + if ratio_spread > STRONG_ENERGY_SPREAD { + has_exceptionally_strong = true; + } } } } // Emit signal if indicators.len() >= MIN_INDICATORS { - let strength_key = if indicators.len() >= 3 { + let strong = indicators.len() >= 3 || (indicators.len() >= 2 && has_exceptionally_strong); + let strength_key = if strong { "signal_watermark_strong" } else { "signal_watermark_moderate" }; + let confidence = if strong { + Confidence::Medium + } else { + Confidence::Low + }; let strength = i18n::t(strength_key, &[]); let indicators_str = indicators.join("; "); Ok(vec![SignalBuilder::new( SignalSource::Watermark, - Confidence::Low, + confidence, "signal_watermark_detected", ) .param("strength", &strength) @@ -418,10 +440,12 @@ pub fn detect_video(path: &Path) -> Result> { i, timestamp ); } + // Invisible watermark analysis match detect(&frame_path) { Ok(signals) if !signals.is_empty() => { // Re-wrap signals with video frame context for signal in signals { + let frame_confidence = signal.confidence; let indicators = signal .details .iter() @@ -431,7 +455,7 @@ pub fn detect_video(path: &Path) -> Result> { all_signals.push( SignalBuilder::new( SignalSource::Watermark, - Confidence::Low, + frame_confidence, "signal_video_frame_watermark", ) .param("frame", format!("{:.1}s", timestamp)) @@ -457,6 +481,19 @@ pub fn detect_video(path: &Path) -> Result> { } } } + + // Visible watermark analysis on the same frame + match super::visible_watermark::detect(&frame_path) { + Ok(signals) if !signals.is_empty() => { + all_signals.extend(signals); + } + Ok(_) => {} + Err(e) => { + if debug { + eprintln!(" [debug] Visible watermark video frame {}: {}", i, e); + } + } + } } } } diff --git a/src/known_tools.rs b/src/known_tools.rs index 29573d8..549db70 100644 --- a/src/known_tools.rs +++ b/src/known_tools.rs @@ -64,6 +64,7 @@ pub const AI_TOOL_PATTERNS: &[&str] = &[ "gemini", "jimeng", "即梦", + "dreamina", // New video generation tools "luma", "hailuo", @@ -97,5 +98,6 @@ mod tests { assert_eq!(match_ai_tool("Canon EOS R5"), None); assert_eq!(match_ai_tool("ComfyUI v1.2"), Some("comfyui")); assert_eq!(match_ai_tool("Midjourney v6"), Some("midjourney")); + assert_eq!(match_ai_tool("Dreamina by ByteDance"), Some("dreamina")); } } diff --git a/tests/fixtures/dreamina_visible_wm.jpg b/tests/fixtures/dreamina_visible_wm.jpg new file mode 100644 index 0000000..976a3fe Binary files /dev/null and b/tests/fixtures/dreamina_visible_wm.jpg differ diff --git a/tests/watermark_detection.rs b/tests/watermark_detection.rs index 34bd606..dfc1de2 100644 --- a/tests/watermark_detection.rs +++ b/tests/watermark_detection.rs @@ -96,3 +96,47 @@ fn watermark_info_command() { .success() .stdout(predicate::str::contains("Watermark Analysis")); } + +#[test] +fn visible_watermark_dreamina_detected_with_deep() { + cargo_bin_cmd!("aic") + .args([ + "--lang", + "en", + "check", + "--deep", + "tests/fixtures/dreamina_visible_wm.jpg", + ]) + .assert() + .success() + .stdout(predicate::str::contains("MEDIUM")) + .stdout(predicate::str::contains("Visible AI watermark badge")); +} + +#[test] +fn visible_watermark_not_on_clean_image() { + cargo_bin_cmd!("aic") + .args([ + "--lang", + "en", + "check", + "--deep", + "tests/fixtures/clean_synthetic.png", + ]) + .assert() + .stdout(predicate::str::contains("Visible").not()); +} + +#[test] +fn visible_watermark_not_on_invisible_watermarked() { + cargo_bin_cmd!("aic") + .args([ + "--lang", + "en", + "check", + "--deep", + "tests/fixtures/watermarked_dwtdct.png", + ]) + .assert() + .stdout(predicate::str::contains("Visible").not()); +}