Skip to content

Commit 4f61441

Browse files
committed
feat(core): add software inpainting cleanup and UI/rendering fixes
Cleanup methods for residual artifacts after reverse alpha blending: - NS (Navier-Stokes), TELEA (fast marching), Soft Inpaint (Gaussian blend) - Gradient-weighted masks from watermark alpha for targeted cleanup - Configurable strength (0-100%), radius (1-25px), method selection - v5→v12b evolution: gradient weight + minimal boundary blur(σ=1.0) UI improvements: - Cleanup controls under Detected Info (teal header, Custom mode only) - NS as default method, 85% strength, 10px radius, enabled by default - Method combo → Strength slider → Radius slider control order Rendering fixes: - Fix window resize flickering: DXGI_SCALING_NONE (was STRETCH) DWM was stretching old back buffer during resize causing visual jitter - Remove unnecessary std::floor layout workarounds
1 parent c0197a7 commit 4f61441

10 files changed

Lines changed: 420 additions & 7 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.21)
2-
project(GeminiWatermarkTool VERSION 0.2.2 LANGUAGES CXX)
2+
project(GeminiWatermarkTool VERSION 0.2.3 LANGUAGES CXX)
33

44
set(CMAKE_CXX_STANDARD 20)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,32 @@ The desktop GUI provides an interactive workflow for both single-image and batch
4040

4141
- Drag & drop or open any supported image
4242
- Auto-detect watermark size (48×48 / 96×96) or select manually
43-
- **Custom watermark mode**: draw a region interactively, resize with 8-point anchors, fine-tune position with WASD keys
43+
- **Custom watermark mode**: draw a search region interactively, resize with 8-point anchors, fine-tune position with WASD keys
44+
- **Multi-scale guided detection (Snap Engine)**: coarse-to-fine NCC template matching auto-locks to the exact watermark position within your drawn region — supports variable sizes from 24–160px
4445
- Real-time before/after comparison (press **V**)
4546
- One-key processing (**X**) and revert (**Z**)
4647
- Zoom, pan (Space/Alt + drag, mouse wheel), and fit-to-window
4748

49+
### Software Inpainting Cleanup (New in v0.2.3)
50+
51+
![Software Inpainting](artworks/gui_inpaint_sw.gif)
52+
53+
Reverse alpha blending is mathematically exact — but only when the image hasn't been resized, recompressed, or processed after watermarking. In practice, many images go through post-processing that breaks the pixel-perfect math, leaving faint residual artifacts after removal.
54+
55+
**Software Inpainting** addresses this by applying a lightweight cleanup pass after the reverse blending step. It uses gradient-weighted masks derived from the watermark's own alpha channel to target only the residual pixels, leaving the rest of the image untouched.
56+
57+
Three built-in methods are available:
58+
59+
| Method | Description | Best for |
60+
|--------|-------------|----------|
61+
| **NS** (default) | Navier-Stokes based inpainting — propagates surrounding pixel flow into the damaged region | General-purpose cleanup with smooth results |
62+
| **TELEA** | Fast marching method — fills inward from boundary pixels based on distance weighting | Quick processing, good for small residuals |
63+
| **Soft Inpaint** | Gradient-weighted Gaussian blend — uses the watermark alpha as a soft mask for weighted blending | Preserving fine texture in photographic content |
64+
65+
The cleanup controls appear automatically in **Custom** mode under the Detected Info panel. You can adjust the **method**, **strength** (0–100%), and **inpaint radius** (1–25 px) to fine-tune the result.
66+
67+
> 🔮 **Coming soon**: A future release will introduce **lightweight AI-based inpainting** (DnCNN / FFDNet, ~2 MB models) for even better cleanup of residuals that conventional methods cannot fully resolve. Stay tuned.
68+
4869
### Batch Processing
4970

5071
<!-- TODO: Replace with actual GIF -->
@@ -555,6 +576,17 @@ original = (watermarked - α × logo) / (1 - α)
555576

556577
This mathematical inversion produces exact restoration of the original pixels.
557578

579+
### Residual Cleanup (Software Inpainting)
580+
581+
When images have been resized or recompressed after watermarking, the exact math no longer holds perfectly. A gradient-weighted inpainting pass can clean up the residual artifacts:
582+
583+
```
584+
1. Compute gradient magnitude from watermark alpha channel
585+
2. Build soft weight mask: stronger where alpha gradient is high
586+
3. Apply selected inpainting method (NS / TELEA / Gaussian blend)
587+
4. Blend result using weight mask — only affected pixels are modified
588+
```
589+
558590
---
559591

560592
## Legal Disclaimer

artworks/gui_inpaint_sw.gif

47.5 MB
Loading

src/core/watermark_engine.cpp

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
#include <opencv2/imgcodecs.hpp>
1919
#include <opencv2/imgproc.hpp>
20+
#include <opencv2/photo.hpp>
2021
#include <spdlog/spdlog.h>
2122
#include <fmt/format.h>
2223
#include <stdexcept>
@@ -713,6 +714,232 @@ GuidedDetectionResult WatermarkEngine::guided_detect(
713714
return result;
714715
}
715716

717+
// =============================================================================
718+
// Inpaint Residual Cleanup
719+
// =============================================================================
720+
721+
void WatermarkEngine::inpaint_residual(
722+
cv::Mat& image,
723+
const cv::Rect& region,
724+
float strength,
725+
InpaintMethod method,
726+
int inpaint_radius,
727+
int padding) const
728+
{
729+
if (image.empty() || region.width < 4 || region.height < 4) {
730+
return;
731+
}
732+
733+
strength = std::clamp(strength, 0.0f, 1.0f);
734+
if (strength < 0.001f) {
735+
return;
736+
}
737+
738+
// =========================================================================
739+
// Common: Calculate padded region
740+
// =========================================================================
741+
cv::Rect padded_rect(
742+
region.x - padding,
743+
region.y - padding,
744+
region.width + padding * 2,
745+
region.height + padding * 2
746+
);
747+
padded_rect &= cv::Rect(0, 0, image.cols, image.rows);
748+
749+
if (padded_rect.width < 8 || padded_rect.height < 8) {
750+
spdlog::warn("inpaint_residual: padded region too small");
751+
return;
752+
}
753+
754+
cv::Rect inner_rect(
755+
region.x - padded_rect.x,
756+
region.y - padded_rect.y,
757+
region.width,
758+
region.height
759+
);
760+
inner_rect &= cv::Rect(0, 0, padded_rect.width, padded_rect.height);
761+
762+
const char* method_name =
763+
(method == InpaintMethod::GAUSSIAN) ? "Soft Inpaint" :
764+
(method == InpaintMethod::TELEA) ? "TELEA" : "NS";
765+
766+
// =========================================================================
767+
// GAUSSIAN (Soft Inpaint): gradient weight + boundary feather
768+
// =========================================================================
769+
if (method == InpaintMethod::GAUSSIAN) {
770+
// --- gradient weight (proven effective, unchanged) ---
771+
772+
// Compute alpha gradient to locate sparkle edges
773+
const cv::Mat& source_alpha = alpha_map_large_;
774+
cv::Mat alpha_resized;
775+
int interp = (region.width > source_alpha.cols)
776+
? cv::INTER_LINEAR : cv::INTER_AREA;
777+
cv::resize(source_alpha, alpha_resized,
778+
cv::Size(region.width, region.height), 0, 0, interp);
779+
780+
cv::Mat grad_x, grad_y, grad_mag;
781+
cv::Sobel(alpha_resized, grad_x, CV_32F, 1, 0, 3);
782+
cv::Sobel(alpha_resized, grad_y, CV_32F, 0, 1, 3);
783+
cv::magnitude(grad_x, grad_y, grad_mag);
784+
785+
double grad_min, grad_max;
786+
cv::minMaxLoc(grad_mag, &grad_min, &grad_max);
787+
if (grad_max <= grad_min) {
788+
spdlog::info("inpaint_residual: flat gradient, no edges found");
789+
return;
790+
}
791+
792+
// Normalize to 0.0-1.0
793+
cv::Mat grad_norm = (grad_mag - grad_min) / (grad_max - grad_min);
794+
795+
// Gamma correction: sqrt expands weak gradient values
796+
cv::Mat grad_weight;
797+
cv::sqrt(grad_norm, grad_weight);
798+
799+
// Dilate to cover residual spread (v5 original: 5×5)
800+
cv::Mat dk = cv::getStructuringElement(
801+
cv::MORPH_ELLIPSE, cv::Size(5, 5));
802+
cv::dilate(grad_weight, grad_weight, dk);
803+
804+
// Smooth weight for natural transitions (v5 original: σ=2.0)
805+
cv::GaussianBlur(grad_weight, grad_weight, cv::Size(0, 0), 2.0);
806+
807+
// Scale by user strength
808+
grad_weight *= strength;
809+
cv::threshold(grad_weight, grad_weight, 1.0, 1.0, cv::THRESH_TRUNC);
810+
811+
// --- Smooth boundary transition (replaces v5 hard cutoff) ---
812+
// Embed gradient weight into padded coordinate system
813+
cv::Mat weight = cv::Mat::zeros(padded_rect.size(), CV_32F);
814+
grad_weight.copyTo(weight(inner_rect));
815+
816+
// Tiny blur to soften the boundary transition
817+
// σ=1.0 affects only ~3px at the inner_rect edge
818+
// Interior (21px from edge on 42×42) is effectively unchanged
819+
// Tips at boundary: weight transitions smoothly instead of hard cutoff
820+
cv::GaussianBlur(weight, weight, cv::Size(0, 0), 1.0);
821+
822+
// --- Gaussian blur the image ---
823+
int ksize = inpaint_radius * 2 + 1;
824+
if (ksize % 2 == 0) ksize++;
825+
ksize = std::max(ksize, 3);
826+
double sigma = inpaint_radius * 0.8;
827+
828+
cv::Mat padded_area = image(padded_rect).clone();
829+
cv::Mat blurred;
830+
cv::GaussianBlur(padded_area, blurred, cv::Size(ksize, ksize), sigma);
831+
832+
// --- Per-pixel weighted blend ---
833+
cv::Mat dst = image(padded_rect);
834+
cv::Mat weight_3ch;
835+
cv::merge(std::vector<cv::Mat>{weight, weight, weight}, weight_3ch);
836+
837+
cv::Mat dst_f, blurred_f, result_f;
838+
dst.convertTo(dst_f, CV_32FC3);
839+
blurred.convertTo(blurred_f, CV_32FC3);
840+
841+
cv::Mat one_minus_w = cv::Scalar(1.0, 1.0, 1.0) - weight_3ch;
842+
cv::multiply(dst_f, one_minus_w, dst_f);
843+
cv::multiply(blurred_f, weight_3ch, blurred_f);
844+
result_f = dst_f + blurred_f;
845+
846+
result_f.convertTo(dst, CV_8UC3);
847+
848+
int active_pixels = cv::countNonZero(weight > 0.01f);
849+
spdlog::info("inpaint_residual: {}, strength={:.0f}%, radius={}, sigma={:.1f}, "
850+
"{} active pixels",
851+
method_name, strength * 100.0f, inpaint_radius, sigma,
852+
active_pixels);
853+
return;
854+
}
855+
856+
// =========================================================================
857+
// TELEA / NS: Sparse gradient mask + cv::inpaint
858+
//
859+
// Uses alpha map gradient to identify sparkle edges, creates binary mask,
860+
// then runs OpenCV inpaint for structural reconstruction.
861+
// These methods are best for edge artifacts on high-contrast boundaries
862+
// (e.g. carpet ↔ floor edges that Gaussian blur would smear).
863+
// =========================================================================
864+
865+
// Compute alpha gradient to locate sparkle edges
866+
const cv::Mat& source_alpha = alpha_map_large_;
867+
cv::Mat alpha_resized;
868+
int interp = (region.width > source_alpha.cols) ? cv::INTER_LINEAR : cv::INTER_AREA;
869+
cv::resize(source_alpha, alpha_resized,
870+
cv::Size(region.width, region.height), 0, 0, interp);
871+
872+
cv::Mat grad_x, grad_y, grad_mag;
873+
cv::Sobel(alpha_resized, grad_x, CV_32F, 1, 0, 3);
874+
cv::Sobel(alpha_resized, grad_y, CV_32F, 0, 1, 3);
875+
cv::magnitude(grad_x, grad_y, grad_mag);
876+
877+
// Normalize to 0-255
878+
double grad_min, grad_max;
879+
cv::minMaxLoc(grad_mag, &grad_min, &grad_max);
880+
if (grad_max <= grad_min) {
881+
spdlog::info("inpaint_residual: flat gradient, no edges found");
882+
return;
883+
}
884+
885+
cv::Mat grad_u8;
886+
grad_mag.convertTo(grad_u8, CV_8U,
887+
255.0 / (grad_max - grad_min),
888+
-grad_min * 255.0 / (grad_max - grad_min));
889+
890+
// Create sparse binary mask at sparkle edges
891+
cv::Mat sparse_mask;
892+
cv::threshold(grad_u8, sparse_mask, 20, 255, cv::THRESH_BINARY);
893+
894+
// Dilate to cover residual spread (1-2px beyond gradient peak)
895+
cv::Mat dilate_kernel = cv::getStructuringElement(
896+
cv::MORPH_ELLIPSE, cv::Size(5, 5));
897+
cv::dilate(sparse_mask, sparse_mask, dilate_kernel);
898+
899+
int masked_pixels = cv::countNonZero(sparse_mask);
900+
if (masked_pixels == 0) {
901+
spdlog::info("inpaint_residual: no edge pixels found, skipping");
902+
return;
903+
}
904+
905+
spdlog::info("inpaint_residual: {} sparse mask {}/{} pixels ({:.1f}%), "
906+
"strength={:.0f}%",
907+
method_name, masked_pixels, region.width * region.height,
908+
100.0f * masked_pixels / (region.width * region.height),
909+
strength * 100.0f);
910+
911+
// Embed mask into padded coordinate system
912+
cv::Mat mask = cv::Mat::zeros(padded_rect.size(), CV_8UC1);
913+
sparse_mask.copyTo(mask(inner_rect));
914+
915+
// Run cv::inpaint
916+
cv::Mat padded_area = image(padded_rect).clone();
917+
int cv_method = (method == InpaintMethod::TELEA)
918+
? cv::INPAINT_TELEA
919+
: cv::INPAINT_NS;
920+
921+
cv::Mat inpainted;
922+
cv::inpaint(padded_area, mask, inpainted, inpaint_radius, cv_method);
923+
924+
// Blend at masked pixels only (unmasked pixels stay untouched)
925+
cv::Mat dst = image(padded_rect);
926+
cv::Mat src_inner = dst(inner_rect);
927+
cv::Mat inp_inner = inpainted(inner_rect);
928+
cv::Mat mask_inner = mask(inner_rect);
929+
930+
if (strength >= 0.999f) {
931+
inp_inner.copyTo(src_inner, mask_inner);
932+
} else {
933+
cv::Mat blended;
934+
cv::addWeighted(src_inner, 1.0 - strength, inp_inner, strength, 0.0, blended);
935+
blended.copyTo(src_inner, mask_inner);
936+
}
937+
938+
spdlog::info("inpaint_residual: applied {} at {:.0f}% strength, radius={}, "
939+
"{} pixels repaired",
940+
method_name, strength * 100.0f, inpaint_radius, masked_pixels);
941+
}
942+
716943
ProcessResult process_image(
717944
const std::filesystem::path& input_path,
718945
const std::filesystem::path& output_path,

src/core/watermark_engine.hpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ WatermarkPosition get_watermark_config(int image_width, int image_height);
7878
*/
7979
WatermarkSize get_watermark_size(int image_width, int image_height);
8080

81+
/**
82+
* Inpaint method for residual cleanup after reverse alpha blend
83+
*/
84+
enum class InpaintMethod {
85+
GAUSSIAN, // Soft-blend: continuous gradient mask + Gaussian blur (recommended)
86+
TELEA, // OpenCV: Fast Marching Method (Telea, 2004)
87+
NS // OpenCV: Navier-Stokes (Bertalmio et al., 2001)
88+
};
89+
8190
/**
8291
* Main watermark engine class
8392
*
@@ -212,6 +221,34 @@ class WatermarkEngine {
212221
*/
213222
const cv::Mat& get_alpha_map(WatermarkSize size) const;
214223

224+
/**
225+
* Apply inpaint cleanup on residual artifacts after reverse alpha blend.
226+
*
227+
* Uses SPARSE MASK derived from alpha map gradient — only repairs the
228+
* sparkle edge pixels where interpolation broke the math, leaving
229+
* correctly-restored pixels untouched.
230+
*
231+
* Two-stage pipeline:
232+
* 1. Reverse alpha blend removes ~90% of watermark (mathematical precision)
233+
* 2. This function cleans the remaining edge artifacts using cv::inpaint
234+
*
235+
* @param image Image after reverse alpha blend (modified in-place)
236+
* @param region The watermark region (where reverse alpha was applied)
237+
* @param strength Blend strength: 0.0 = keep reverse-alpha result,
238+
* 1.0 = fully replace with inpainted result
239+
* @param method Inpaint method (TELEA or NS)
240+
* @param inpaint_radius Inpaint radius for cv::inpaint (default: 3)
241+
* @param padding Context padding around region in pixels (default: 16)
242+
*/
243+
void inpaint_residual(
244+
cv::Mat& image,
245+
const cv::Rect& region,
246+
float strength = 0.85f,
247+
InpaintMethod method = InpaintMethod::NS,
248+
int inpaint_radius = 10,
249+
int padding = 32
250+
) const;
251+
215252
private:
216253
cv::Mat alpha_map_small_; // 48x48 alpha map (CV_32FC1, 0.0-1.0)
217254
cv::Mat alpha_map_large_; // 96x96 alpha map (CV_32FC1, 0.0-1.0)

0 commit comments

Comments
 (0)