diff --git a/docs/module-internals.md b/docs/module-internals.md index 0dfa086..f2410f1 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1391,6 +1391,13 @@ The shared text engine handles `千问AI生成`; `QwenEngine` also scans a squar bottom-right region for Qwen Create's three-lobe symbol. Its 0.65 NCC gate was measured against the cleared provider fixture (0.848) and 132 decoded tracked controls other than that positive (maximum 0.516 on 2026-09-10, zero fires). +NCC is not portable across OpenCV builds: issue 108 reported 0.81 on +`data/fixtures/provenance/chatgpt-1.png` (sand and denim, no glyph) while this +tree's OpenCV 4.11 scores 0.437 on the same file. A fire also requires +binarized IoU of the bright residual with the three-lobe silhouette at or +above 0.70; the true fixture stays at or above 0.75 down to 512 px, and +chatgpt-1 sits at 0.55. `provenance_ncc_factor` remains 1.0, so OpenAI C2PA +cannot relax this mark. The removal mask reuses the aligned three-lobe alpha and expands it by 6% of the detected side; a solid square was rejected because classical inpainting left a visible rectangular blur. diff --git a/pyproject.toml b/pyproject.toml index 5e88759..9fa9f7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ # on it, including the ComfyUI node package. The console script below carries # the same weight, since users have it on PATH. name = "remove-ai-watermarks" -version = "0.40.0" +version = "0.40.1" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 4f4b1ec..1a13087 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.40.0" +__version__ = "0.40.1" __all__ = [ "BatchItemResult", diff --git a/src/remove_ai_watermarks/qwen_engine.py b/src/remove_ai_watermarks/qwen_engine.py index 5d48732..39a9c9d 100644 --- a/src/remove_ai_watermarks/qwen_engine.py +++ b/src/remove_ai_watermarks/qwen_engine.py @@ -109,6 +109,14 @@ _SYMBOL_MARGIN_FRAC = 0.012 _SYMBOL_SIZE_FRAC = 0.03125 _SYMBOL_LADDER = (0.75, 0.875, 1.0, 1.125, 1.25) _SYMBOL_NCC_THRESHOLD = 0.65 +# NCC alone is not portable: issue 108 reported 0.81 on +# data/fixtures/provenance/chatgpt-1.png (sand/denim, no glyph) while this +# OpenCV build scores 0.437 on the same file. True Qwen Create matches keep +# binarized IoU >= 0.75 down to 512 px; that fixture sits at 0.55. Require +# both the NCC gate and this shape overlap so a high-NCC blob cannot fire. +_SYMBOL_SHAPE_IOU_THRESHOLD = 0.70 +_SYMBOL_TEMPLATE_MASK_FRAC = 0.25 +_SYMBOL_RESPONSE_MASK_FRAC = 0.35 _SYMBOL_MIN_SHORT_SIDE = 256 _SYMBOL_MASK_ALPHA = 4 / 255 _SYMBOL_MASK_DILATE_FRAC = 0.06 @@ -165,6 +173,23 @@ class QwenEngine(TextMarkEngine): self._symbol_template = template self._symbol_template_scales: dict[int, NDArray[Any]] = {} + @staticmethod + def _symbol_shape_iou(response_crop: NDArray[Any], template: NDArray[Any]) -> float: + """Overlap of the bright residual with the three-lobe silhouette.""" + if response_crop.size == 0 or template.size == 0: + return 0.0 + template_peak = float(template.max()) + response_peak = float(response_crop.max()) + if template_peak <= 0.0 or response_peak <= 0.0: + return 0.0 + template_mask = template > (template_peak * _SYMBOL_TEMPLATE_MASK_FRAC) + response_mask = response_crop > (response_peak * _SYMBOL_RESPONSE_MASK_FRAC) + union = int(np.logical_or(template_mask, response_mask).sum()) + if union == 0: + return 0.0 + intersection = int(np.logical_and(template_mask, response_mask).sum()) + return intersection / union + def _scaled_symbol_template(self, side: int) -> NDArray[Any]: """Return a cached square symbol template for a measured ladder size.""" template = self._symbol_template_scales.get(side) @@ -195,6 +220,7 @@ class QwenEngine(TextMarkEngine): best_score = 0.0 best_region = (0, 0, 0, 0) + best_iou = 0.0 for rung in _SYMBOL_LADDER: side = max(12, int(base * _SYMBOL_SIZE_FRAC * rung)) if side >= search_side: @@ -202,11 +228,15 @@ class QwenEngine(TextMarkEngine): template = self._scaled_symbol_template(side) scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED) _minimum, score, _min_location, location = cv2.minMaxLoc(scores) - if score > best_score: - best_score = float(score) - best_region = (origin_x + location[0], origin_y + location[1], side, side) + ncc = float(score) + if ncc <= best_score: + continue + crop = response[location[1] : location[1] + side, location[0] : location[0] + side] + best_score = ncc + best_region = (origin_x + location[0], origin_y + location[1], side, side) + best_iou = self._symbol_shape_iou(crop, template) det.confidence = best_score - det.detected = best_score >= _SYMBOL_NCC_THRESHOLD + det.detected = best_score >= _SYMBOL_NCC_THRESHOLD and best_iou >= _SYMBOL_SHAPE_IOU_THRESHOLD det.region = best_region return det diff --git a/tests/test_qwen_engine.py b/tests/test_qwen_engine.py index 38a726e..75d1afc 100644 --- a/tests/test_qwen_engine.py +++ b/tests/test_qwen_engine.py @@ -20,6 +20,7 @@ from remove_ai_watermarks.qwen_engine import ( _ALPHA_HEIGHT_FRAC, _ALPHA_WIDTH_FRAC, _LADDER, + _SYMBOL_SHAPE_IOU_THRESHOLD, QwenEngine, _alpha_template, _glyph_silhouette, @@ -32,6 +33,7 @@ _BIG_MODE, _SMALL_MODE = 0.203, 0.124 _MARGIN = 0.025 # measured right/bottom margin of the real mark _ROOT = Path(__file__).resolve().parents[1] _QWEN_CREATE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png" +_CHATGPT_1 = _ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png" def _compose(w: int, h: int, mode: float = _BIG_MODE, bg: float = 100.0): @@ -146,6 +148,26 @@ class TestDetect: assert y > image.shape[0] * 0.90 assert width == height + def test_chatgpt_1_fixture_does_not_fire_qwen(self): + # Issue 108: 0.40.0 reported visible_qwen 0.81 on this OpenAI C2PA + # beach photo and auto-inpainted sand. NCC on the three-lobe template + # is OpenCV-sensitive; the shape overlap must still refuse it. + from remove_ai_watermarks.identify import identify + from remove_ai_watermarks.image_io import imread + + image = imread(_CHATGPT_1) + assert image is not None + det = QwenEngine().detect(image) + assert not det.detected + record = identify(_CHATGPT_1, check_visible=True, check_invisible=False) + assert all(signal.name != "visible_qwen" for signal in record.signals) + + def test_symbol_shape_iou_rejects_a_solid_blob(self): + template = QwenEngine()._scaled_symbol_template(32) + blob = np.full_like(template, float(template.max())) + assert QwenEngine._symbol_shape_iou(blob, template) < _SYMBOL_SHAPE_IOU_THRESHOLD + assert QwenEngine._symbol_shape_iou(template, template) >= _SYMBOL_SHAPE_IOU_THRESHOLD + def test_unaccepted_symbol_candidate_cannot_hide_detected_text(self): text_detection = TextMarkDetection(detected=True, confidence=0.45) symbol_detection = TextMarkDetection(detected=False, confidence=0.46) @@ -176,6 +198,16 @@ class TestFootprintMaskAndRemoval: clean = cv2.GaussianBlur(np.full((640, 853, 3), 120, np.uint8), (5, 5), 0) assert QwenEngine().footprint_mask(clean, force=False) is None + def test_chatgpt_1_auto_remove_does_not_touch_pixels(self): + from remove_ai_watermarks.api import remove_visible_detailed + from remove_ai_watermarks.image_io import imread + + image = imread(_CHATGPT_1) + assert image is not None + report = remove_visible_detailed(image, sensitivity="auto") + assert report.status == "no_watermark" + assert np.array_equal(report.image, image) + def test_removes_qwen_create_symbol(self): image = cv2.imread(str(_QWEN_CREATE), cv2.IMREAD_COLOR) assert image is not None diff --git a/uv.lock b/uv.lock index d5ab0e0..7211a88 100644 --- a/uv.lock +++ b/uv.lock @@ -3449,7 +3449,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.40.0" +version = "0.40.1" source = { editable = "." } dependencies = [ { name = "c2pa-python" },