apply custom text
This commit is contained in:
@@ -170,8 +170,8 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
|
|||||||
let scaled_font = PxScale::from(scale_px);
|
let scaled_font = PxScale::from(scale_px);
|
||||||
let (t_width, _t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
|
let (t_width, _t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
|
||||||
|
|
||||||
// 3. Ensure it fits width (Padding 5%)
|
// 3. Ensure it fits width (Padding 10%)
|
||||||
let max_width = (width as f32 * 0.95) as u32;
|
let max_width = (width as f32 * 0.90) as u32;
|
||||||
if t_width > max_width {
|
if t_width > max_width {
|
||||||
let ratio = max_width as f32 / t_width as f32;
|
let ratio = max_width as f32 / t_width as f32;
|
||||||
scale_px *= ratio;
|
scale_px *= ratio;
|
||||||
@@ -277,56 +277,94 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
|
|||||||
fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String> {
|
fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String> {
|
||||||
let (width, height) = img.dimensions();
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
let bottom_start_y = (height as f64 * 0.8) as u32;
|
// Greedy Layered Search
|
||||||
let zone_height = height - bottom_start_y;
|
// Priority: Bottom -> Up
|
||||||
let zone_width = width / 3;
|
let y_levels = [0.97, 0.94, 0.91, 0.88];
|
||||||
|
let x_cols = [1.0/6.0, 3.0/6.0, 5.0/6.0]; // Left, Center, Right centers
|
||||||
|
let col_names = ["Left", "Center", "Right"];
|
||||||
|
|
||||||
let zones = [
|
// Box Size for analysis (approx watermark size)
|
||||||
("Left", 0, bottom_start_y),
|
let box_w = (width as f64 * 0.30) as u32;
|
||||||
("Center", zone_width, bottom_start_y),
|
let box_h = (height as f64 * 0.05) as u32;
|
||||||
("Right", zone_width * 2, bottom_start_y),
|
let half_box_w = box_w / 2;
|
||||||
];
|
let half_box_h = box_h / 2;
|
||||||
|
|
||||||
let mut min_std_dev = f64::MAX;
|
let mut global_best_score = f64::MAX;
|
||||||
let mut best_zone = "Center";
|
let mut global_best_result = ZcaResult { x: 0.5, y: 0.97, zone: "Center".to_string() };
|
||||||
let mut best_pos = (0.5, 0.97);
|
|
||||||
|
|
||||||
for (name, start_x, start_y) in zones.iter() {
|
for &y_pct in y_levels.iter() {
|
||||||
let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize);
|
let mut row_best_score = f64::MAX;
|
||||||
|
let mut row_best_idx = 1; // Default Center
|
||||||
|
let mut row_stats = Vec::new(); // (mean, std_dev)
|
||||||
|
|
||||||
for y in *start_y..height {
|
for (col_idx, &x_pct) in x_cols.iter().enumerate() {
|
||||||
for x in *start_x..(*start_x + zone_width) {
|
let cx = (width as f64 * x_pct) as u32;
|
||||||
if x >= width { continue; }
|
let cy = (height as f64 * y_pct) as u32;
|
||||||
let pixel = img.get_pixel(x, y);
|
|
||||||
let rgb = pixel.to_rgb();
|
let start_x = if cx > half_box_w { cx - half_box_w } else { 0 };
|
||||||
let luma = 0.299 * rgb[0] as f64 + 0.587 * rgb[1] as f64 + 0.114 * rgb[2] as f64;
|
let start_y = if cy > half_box_h { cy - half_box_h } else { 0 };
|
||||||
luma_values.push(luma);
|
let end_x = (start_x + box_w).min(width);
|
||||||
|
let end_y = (start_y + box_h).min(height);
|
||||||
|
|
||||||
|
let mut luma_values = Vec::with_capacity((box_w * box_h) as usize);
|
||||||
|
|
||||||
|
for y in start_y..end_y {
|
||||||
|
for x in start_x..end_x {
|
||||||
|
let pixel = img.get_pixel(x, y);
|
||||||
|
let rgb = pixel.to_rgb();
|
||||||
|
let luma = 0.299 * rgb[0] as f64 + 0.587 * rgb[1] as f64 + 0.114 * rgb[2] as f64;
|
||||||
|
luma_values.push(luma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = luma_values.len() as f64;
|
||||||
|
if count == 0.0 {
|
||||||
|
row_stats.push((0.0, f64::MAX));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mean = luma_values.iter().sum::<f64>() / count;
|
||||||
|
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
|
||||||
|
let std_dev = variance.sqrt();
|
||||||
|
|
||||||
|
row_stats.push((mean, std_dev));
|
||||||
|
|
||||||
|
// For choosing "Best in Row", we strictly prefer Flatness (StdDev)
|
||||||
|
if std_dev < row_best_score {
|
||||||
|
row_best_score = std_dev;
|
||||||
|
row_best_idx = col_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Global Best (fallback)
|
||||||
|
if std_dev < global_best_score {
|
||||||
|
global_best_score = std_dev;
|
||||||
|
global_best_result = ZcaResult {
|
||||||
|
x: x_pct,
|
||||||
|
y: y_pct,
|
||||||
|
zone: col_names[col_idx].to_string(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = luma_values.len() as f64;
|
// Analyze the Best Zone in this Row
|
||||||
if count == 0.0 { continue; }
|
let (mean, std_dev) = row_stats[row_best_idx];
|
||||||
|
|
||||||
let mean = luma_values.iter().sum::<f64>() / count;
|
// Safety Check: Is this zone "White Text"?
|
||||||
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
|
// Condition: Mean > 180 (Bright-ish) AND StdDev > 20 (Busy/Text)
|
||||||
let std_dev = variance.sqrt();
|
let is_unsafe_white_text = mean > 180.0 && std_dev > 20.0;
|
||||||
|
let is_unsafe_bright = mean > 230.0;
|
||||||
|
|
||||||
if std_dev < min_std_dev {
|
if !is_unsafe_white_text && !is_unsafe_bright {
|
||||||
min_std_dev = std_dev;
|
// Safe!
|
||||||
best_zone = name;
|
return Ok(ZcaResult {
|
||||||
let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0);
|
x: x_cols[row_best_idx],
|
||||||
// Position closer to bottom
|
y: y_pct,
|
||||||
// 0.8 + 0.2 * 0.85 = 0.97
|
zone: col_names[row_best_idx].to_string(),
|
||||||
let center_y_px = *start_y as f64 + (zone_height as f64 * 0.85);
|
});
|
||||||
best_pos = (center_x_px / width as f64, center_y_px / height as f64);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ZcaResult {
|
Ok(global_best_result)
|
||||||
x: best_pos.0,
|
|
||||||
y: best_pos.1,
|
|
||||||
zone: best_zone.to_string(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -335,6 +373,63 @@ fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
|
|||||||
calculate_zca_internal(&img)
|
calculate_zca_internal(&img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct LayoutResult {
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
scale: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn layout_watermark(path: String, text: String, base_scale: f64) -> Result<LayoutResult, String> {
|
||||||
|
let img = image::open(&path).map_err(|e| e.to_string())?;
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
|
||||||
|
|
||||||
|
// 1. Run ZCA to find best zone center (now with dark preference)
|
||||||
|
let zca = calculate_zca_internal(&img)?;
|
||||||
|
|
||||||
|
// 2. Calculate Text Dimensions at Base Scale
|
||||||
|
let mut scale_val = base_scale;
|
||||||
|
let mut scale_px = height as f32 * scale_val as f32;
|
||||||
|
let mut font_scale = PxScale::from(scale_px);
|
||||||
|
let (mut t_width, mut t_height) = imageproc::drawing::text_size(font_scale, &font, &text);
|
||||||
|
|
||||||
|
// 3. Auto-Fit Width (Limit to 90% of image width)
|
||||||
|
let max_width = (width as f32 * 0.90) as u32;
|
||||||
|
if t_width > max_width {
|
||||||
|
let ratio = max_width as f32 / t_width as f32;
|
||||||
|
scale_val *= ratio as f64;
|
||||||
|
scale_px *= ratio;
|
||||||
|
font_scale = PxScale::from(scale_px);
|
||||||
|
let dims = imageproc::drawing::text_size(font_scale, &font, &text);
|
||||||
|
t_width = dims.0;
|
||||||
|
t_height = dims.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Smart Clamping
|
||||||
|
let center_x = zca.x * width as f64;
|
||||||
|
let center_y = zca.y * height as f64;
|
||||||
|
|
||||||
|
let half_w = t_width as f64 / 2.0;
|
||||||
|
let half_h = t_height as f64 / 2.0;
|
||||||
|
let padding = width as f64 * 0.02;
|
||||||
|
|
||||||
|
let min_x = half_w + padding;
|
||||||
|
let max_x = width as f64 - half_w - padding;
|
||||||
|
let final_x = center_x.clamp(min_x, max_x);
|
||||||
|
|
||||||
|
let min_y = half_h + padding;
|
||||||
|
let max_y = height as f64 - half_h - padding;
|
||||||
|
let final_y = center_y.clamp(min_y, max_y);
|
||||||
|
|
||||||
|
Ok(LayoutResult {
|
||||||
|
x: final_x / width as f64,
|
||||||
|
y: final_y / height as f64,
|
||||||
|
scale: scale_val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct DetectionResult {
|
struct DetectionResult {
|
||||||
rects: Vec<Rect>,
|
rects: Vec<Rect>,
|
||||||
@@ -453,7 +548,7 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch, detect_watermark])
|
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch, detect_watermark, layout_watermark])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGalleryStore } from "../stores/gallery";
|
import { useGalleryStore } from "../stores/gallery";
|
||||||
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2 } from 'lucide-vue-next';
|
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2, RotateCw } from 'lucide-vue-next';
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const store = useGalleryStore();
|
const store = useGalleryStore();
|
||||||
@@ -89,14 +89,23 @@ const applyAll = () => {
|
|||||||
<!-- Text Input -->
|
<!-- Text Input -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
|
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
|
||||||
<div class="relative">
|
<div class="flex gap-2">
|
||||||
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
<div class="relative flex-1">
|
||||||
<input
|
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||||
type="text"
|
<input
|
||||||
v-model="store.watermarkSettings.text"
|
type="text"
|
||||||
class="w-full bg-gray-700 text-white pl-10 pr-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
v-model="store.watermarkSettings.text"
|
||||||
placeholder="Enter text..."
|
class="w-full bg-gray-700 text-white pl-10 pr-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||||
/>
|
placeholder="Enter text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="store.recalcCurrentWatermark()"
|
||||||
|
class="bg-blue-600 hover:bg-blue-500 text-white p-2 rounded flex items-center justify-center transition-colors"
|
||||||
|
title="Apply & Recalculate Layout"
|
||||||
|
>
|
||||||
|
<RotateCw class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path });
|
const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path });
|
||||||
|
|
||||||
if (result.rects && result.rects.length > 0) {
|
if (result.rects && result.rects.length > 0) {
|
||||||
// Append to existing strokes? Or replace?
|
|
||||||
// User said "first automatically detect... but allow manual paint".
|
|
||||||
// Usually detection is a starting point. Let's append.
|
|
||||||
// (If user wants to restart, they can Clear first).
|
|
||||||
if (!img.maskStrokes) img.maskStrokes = [];
|
if (!img.maskStrokes) img.maskStrokes = [];
|
||||||
|
|
||||||
result.rects.forEach(r => {
|
result.rects.forEach(r => {
|
||||||
@@ -122,6 +118,26 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recalcCurrentWatermark() {
|
||||||
|
if (selectedIndex.value < 0 || !selectedImage.value) return;
|
||||||
|
const img = selectedImage.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<{x: number, y: number, scale: number}>("layout_watermark", {
|
||||||
|
path: img.path,
|
||||||
|
text: watermarkSettings.value.text,
|
||||||
|
baseScale: watermarkSettings.value.scale
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply to current image
|
||||||
|
setImageManualPosition(selectedIndex.value, result.x, result.y);
|
||||||
|
setImageSetting(selectedIndex.value, 'scale', result.scale);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Layout failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Applies the settings from the CURRENT image (or global if not overridden) to ALL images
|
// Applies the settings from the CURRENT image (or global if not overridden) to ALL images
|
||||||
// Strategy: Update Global Settings to match current view, and clear individual overrides so everyone follows global.
|
// Strategy: Update Global Settings to match current view, and clear individual overrides so everyone follows global.
|
||||||
function applySettingsToAll() {
|
function applySettingsToAll() {
|
||||||
@@ -175,6 +191,7 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
applySettingsToAll,
|
applySettingsToAll,
|
||||||
addMaskStroke,
|
addMaskStroke,
|
||||||
clearMask,
|
clearMask,
|
||||||
detectWatermark
|
detectWatermark,
|
||||||
|
recalcCurrentWatermark
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user