diff --git a/qml/components/BusyStatus.qml b/qml/components/BusyStatus.qml index 07a2495..af76adb 100644 --- a/qml/components/BusyStatus.qml +++ b/qml/components/BusyStatus.qml @@ -10,31 +10,23 @@ Rectangle { border.color: AppTheme.border border.width: 1 color: AppTheme.surface - height: 44 - radius: 22 + height: 32 + radius: 16 visible: running - width: Math.max(140, label.contentWidth + 50) + width: Math.max(110, contentRow.width + 24) - // Shadow effect - Rectangle { - anchors.fill: parent - anchors.leftMargin: 2 - anchors.topMargin: 2 - color: AppTheme.shadowColor - radius: 22 - z: -1 - } Row { + id: contentRow + anchors.centerIn: parent - spacing: 12 + spacing: 8 - // Spinner Item { - height: 20 - width: 20 + height: 14 + width: 14 RotationAnimator on rotation { - duration: 1000 + duration: 800 from: 0 loops: Animation.Infinite running: root.running @@ -43,25 +35,27 @@ Rectangle { Canvas { anchors.fill: parent + antialiasing: true + renderTarget: Canvas.Image onPaint: { var ctx = getContext("2d"); ctx.reset(); ctx.beginPath(); - ctx.arc(10, 10, 8, 0, Math.PI * 1.5); - ctx.lineWidth = 2; + ctx.arc(7, 7, 5.5, 0, Math.PI * 1.5); + ctx.lineWidth = 1.5; + ctx.lineCap = "round"; ctx.strokeStyle = AppTheme.primary; ctx.stroke(); } } } - Text { - id: label + Text { color: AppTheme.text font.family: AppTheme.fontFamily - font.pixelSize: AppTheme.fontSizeBody - font.weight: Font.DemiBold + font.pixelSize: AppTheme.fontSizeSmall + font.weight: Font.Medium text: root.text verticalAlignment: Text.AlignVCenter } diff --git a/qml/components/ToolbarButton.qml b/qml/components/ToolbarButton.qml index 5c23ba4..1ed511d 100644 --- a/qml/components/ToolbarButton.qml +++ b/qml/components/ToolbarButton.qml @@ -12,6 +12,7 @@ Basic.Button { property color hoveredIconColor: defaultIconColor property bool isActive: false property string tooltipText: "" + property bool showTooltip: true Layout.preferredHeight: AppTheme.buttonHeight Layout.preferredWidth: AppTheme.buttonHeight @@ -34,7 +35,7 @@ Basic.Button { Tooltip { parent: root text: root.tooltipText - visible: root.hovered && root.tooltipText !== "" + visible: root.showTooltip && root.hovered && root.tooltipText !== "" position: Qt.AlignTop } } diff --git a/qml/features/capture/LongCapturePreviewWindow.qml b/qml/features/capture/LongCapturePreviewWindow.qml index dcdb504..2dbc9b1 100644 --- a/qml/features/capture/LongCapturePreviewWindow.qml +++ b/qml/features/capture/LongCapturePreviewWindow.qml @@ -15,7 +15,7 @@ Window { if (imgPhysicalW <= 0) return 150; - let viewW = width - 2; // minus border + let viewW = width; let ratio = viewW / imgPhysicalW; let h = currentHeight * ratio; return Math.max(100, h); @@ -37,7 +37,7 @@ Window { color: "transparent" flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool - height: Math.min(contentHeight + 2, 600) // Cap at 600px, +2 for border + height: Math.min(contentHeight, 600) width: 300 @@ -52,7 +52,7 @@ Window { // Shadow for depth Rectangle { - color: AppTheme.shadowHeavy + color: AppTheme.shadowMedium height: parent.height radius: AppTheme.radiusLarge width: parent.width @@ -63,8 +63,6 @@ Window { id: mainRect anchors.fill: parent - border.color: AppTheme.border - border.width: 1 clip: true color: AppTheme.surface radius: AppTheme.radiusLarge @@ -83,7 +81,6 @@ Window { id: previewImg anchors.fill: parent - anchors.margins: 1 cache: false // Default to cropping (showing latest content) for better feedback during scroll fillMode: root.showFull ? Image.PreserveAspectFit : Image.PreserveAspectCrop @@ -93,41 +90,31 @@ Window { sourceSize.width: parent.width * Screen.devicePixelRatio verticalAlignment: Image.AlignBottom - // Loading / Active State Item { anchors.centerIn: parent - height: 48 + height: 24 visible: root.currentHeight === 0 - width: 48 + width: 24 RotationAnimator on rotation { - duration: 1500 + duration: 800 from: 0 loops: Animation.Infinite to: 360 } - // Simple spinner using Rectangles - Rectangle { - border.color: AppTheme.subText - border.width: AppTheme.spacingTiny - color: "transparent" - height: 48 - opacity: 0.1 - radius: AppTheme.radiusLarge - width: 48 - } - - // Arc segment (simulated with canvas for clean look) Canvas { anchors.fill: parent + antialiasing: true + renderTarget: Canvas.Image onPaint: { var ctx = getContext("2d"); ctx.reset(); ctx.beginPath(); - ctx.arc(width / 2, height / 2, width / 2 - 2, 0, Math.PI / 1.5); - ctx.lineWidth = AppTheme.spacingTiny; + ctx.arc(12, 12, 9, 0, Math.PI * 1.5); + ctx.lineWidth = 2; + ctx.lineCap = "round"; ctx.strokeStyle = AppTheme.primary; ctx.stroke(); } @@ -135,7 +122,7 @@ Window { } Text { anchors.centerIn: parent - anchors.verticalCenterOffset: 40 + anchors.verticalCenterOffset: 24 color: AppTheme.subText font.bold: true font.pixelSize: 12 @@ -147,14 +134,15 @@ Window { // Minimal Floating Badge Rectangle { anchors.bottom: parent.bottom - anchors.margins: 6 + anchors.margins: 8 anchors.right: parent.right color: AppTheme.primary - height: 20 + height: 24 + opacity: 0.9 // Add shadow to badge too layer.enabled: true - radius: AppTheme.radiusMedium + radius: 12 visible: root.currentHeight > 0 width: badgeTxt.contentWidth + AppTheme.spacingMedium diff --git a/qml/features/capture/LongCaptureToolbarWindow.qml b/qml/features/capture/LongCaptureToolbarWindow.qml index f5f355b..f0d3ff7 100644 --- a/qml/features/capture/LongCaptureToolbarWindow.qml +++ b/qml/features/capture/LongCaptureToolbarWindow.qml @@ -43,12 +43,13 @@ Window { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter + showTooltips: false buttons: [[ { "icon": "qrc:/resources/icons/save.svg", "text": qsTr("Save"), "action": "save", - "hoverColor": AppTheme.success + "hoverColor": AppTheme.primary }, { "icon": "qrc:/resources/icons/keep.svg", @@ -82,4 +83,4 @@ Window { running: isBusy text: root.busyText } -} \ No newline at end of file +} diff --git a/qml/features/capture/components/SelectionToolbar.qml b/qml/features/capture/components/SelectionToolbar.qml index bad86ab..3ae0a15 100644 --- a/qml/features/capture/components/SelectionToolbar.qml +++ b/qml/features/capture/components/SelectionToolbar.qml @@ -8,6 +8,7 @@ Rectangle { id: root property string activeTool: "" + property bool showTooltips: true property var buttons: [[ { "icon": "qrc:/resources/icons/square.svg", @@ -144,6 +145,7 @@ Rectangle { icon.source: modelData.icon icon.width: 24 isActive: modelData.action === root.activeTool + showTooltip: root.showTooltips tooltipText: modelData.text onClicked: { diff --git a/src/core/capture/scroll_worker.rs b/src/core/capture/scroll_worker.rs index 155597f..e491792 100644 --- a/src/core/capture/scroll_worker.rs +++ b/src/core/capture/scroll_worker.rs @@ -2,8 +2,8 @@ use crate::core::capture::stitcher::{ScrollStitcher, StitchResult}; use crate::core::capture::{get_primary_monitor, get_primary_monitor_scale, perform_crop}; use image::RgbaImage; use log::{error, info}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::Duration; @@ -21,7 +21,7 @@ pub fn start_scroll_capture_thread(x: i32, y: i32, width: i32, height: i32, acti .spawn(move || { info!("Scroll capture thread started"); // Small delay to let UI hide if needed - thread::sleep(Duration::from_millis(150)); + thread::sleep(Duration::from_millis(250)); let Some(monitor) = get_primary_monitor() else { error!("No primary monitor found for scroll capture"); diff --git a/src/core/capture/stitcher.rs b/src/core/capture/stitcher.rs index 097c773..33b4c0b 100644 --- a/src/core/capture/stitcher.rs +++ b/src/core/capture/stitcher.rs @@ -1,5 +1,7 @@ use image::RgbaImage; +const CANVAS_RESIZE_HEADROOM: u32 = 2000; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum StitchResult { Success, @@ -7,18 +9,31 @@ pub enum StitchResult { Failure, } +#[derive(Debug, Clone, Copy, PartialEq)] pub struct StitchConfig { pub min_overlap: u32, - pub pixel_tolerance: u8, pub min_scroll_threshold: u32, + pub overlap_avg_threshold: u64, + pub motion_scan_step: usize, + pub motion_threshold_divisor: u64, + pub fixed_diff_percent: u64, + pub verify_pixel_diff: u32, + pub verify_step_divisor: usize, + pub seam_margin_divisor: u32, } impl Default for StitchConfig { fn default() -> Self { Self { - min_overlap: 50, - pixel_tolerance: 10, - min_scroll_threshold: 15, + min_overlap: 20, + min_scroll_threshold: 5, + overlap_avg_threshold: 500, + motion_scan_step: 8, + motion_threshold_divisor: 2, + fixed_diff_percent: 5, + verify_pixel_diff: 80, + verify_step_divisor: 20, + seam_margin_divisor: 4, } } } @@ -39,13 +54,7 @@ impl Default for ScrollStitcher { impl ScrollStitcher { pub fn new() -> Self { - Self { - canvas: None, - valid_height: 0, - last_frame: None, - last_footer_height: 0, - config: StitchConfig::default(), - } + Self::with_config(StitchConfig::default()) } pub fn with_config(config: StitchConfig) -> Self { @@ -58,39 +67,38 @@ impl ScrollStitcher { } } - #[must_use] pub fn current_image(&self) -> Option<(&RgbaImage, u32)> { self.canvas.as_ref().map(|img| (img, self.valid_height)) } - #[must_use] pub fn get_final_image(&self) -> Option { let (canvas, h) = self.current_image()?; - let w = canvas.width(); - let mut final_img = RgbaImage::new(w, h); + if h == 0 || canvas.width() == 0 { + return None; + } + let mut final_img = RgbaImage::new(canvas.width(), h); Self::copy_region(canvas, 0, &mut final_img, 0, h); Some(final_img) } - #[must_use] pub fn make_thumbnail(&self, target_width: u32) -> Option { let (canvas, valid_h) = self.current_image()?; let w = canvas.width(); - if valid_h == 0 || w == 0 { return None; } let scale = target_width as f32 / w as f32; let target_height = (valid_h as f32 * scale) as u32; - if target_height == 0 { return None; } - let view = image::imageops::crop_imm(canvas, 0, 0, w, valid_h).to_image(); + let mut cropped = RgbaImage::new(w, valid_h); + Self::copy_region(canvas, 0, &mut cropped, 0, valid_h); + Some(image::imageops::resize( - &view, + &cropped, target_width, target_height, image::imageops::FilterType::Triangle, @@ -99,82 +107,272 @@ impl ScrollStitcher { pub fn process_frame(&mut self, new_image: RgbaImage) -> StitchResult { if self.canvas.is_none() { - let w = new_image.width(); - let h = new_image.height(); - let capacity = h * 2; - let mut canvas = RgbaImage::new(w, capacity); - - Self::copy_region(&new_image, 0, &mut canvas, 0, h); - - self.canvas = Some(canvas); - self.valid_height = h; - self.last_frame = Some(new_image); - self.last_footer_height = 0; + self.initialize_canvas(new_image); return StitchResult::Success; } let last_frame = self.last_frame.as_ref().unwrap(); - if last_frame.width() != new_image.width() { return StitchResult::Failure; } - let (fixed_top, fixed_bottom) = self.detect_fixed_regions(last_frame, &new_image); - - let prev_h = last_frame.height(); - let next_h = new_image.height(); + let h_prev = last_frame.height(); + let h_next = new_image.height(); - if prev_h <= fixed_top + fixed_bottom || next_h <= fixed_top + fixed_bottom { - return StitchResult::Failure; + let active_mask = self.compute_motion_mask(last_frame, &new_image); + if active_mask.is_empty() { + self.last_frame = Some(new_image); + return StitchResult::Stationary; } - let valid_prev_h = prev_h - fixed_top - fixed_bottom; - let valid_next_h = next_h - fixed_top - fixed_bottom; + let sig_prev = self.compute_masked_signatures(last_frame, &active_mask); + let sig_next = self.compute_masked_signatures(&new_image, &active_mask); - let prev_sig = self.compute_row_signatures(last_frame, fixed_top, valid_prev_h); - let next_sig = self.compute_row_signatures(&new_image, fixed_top, valid_next_h); + let (fixed_top, fixed_bottom) = self.detect_fixed_regions_sig(&sig_prev, &sig_next); - let best_overlap = self.find_best_overlap(&prev_sig, &next_sig); + let valid_prev = h_prev.saturating_sub(fixed_top + fixed_bottom); + let valid_next = h_next.saturating_sub(fixed_top + fixed_bottom); - if best_overlap == 0 { + if valid_prev < self.config.min_overlap || valid_next < self.config.min_overlap { return StitchResult::Failure; } - let check_offset = best_overlap / 2; - let verify_y_prev = prev_h - fixed_bottom - best_overlap + check_offset; - let verify_y_next = fixed_top + check_offset; - let stride = (last_frame.width() * 4) as usize; + let (best_overlap, is_reverse) = self.find_optimal_overlap(&sig_prev, &sig_next, fixed_top, fixed_bottom, valid_prev, valid_next); - if !self.rows_match_sampled(last_frame.as_raw(), new_image.as_raw(), stride, verify_y_prev, verify_y_next) { + if best_overlap == 0 { return StitchResult::Failure; } - let scroll_delta = valid_prev_h.saturating_sub(best_overlap); + if !self.verify_pixels(last_frame, &new_image, best_overlap, is_reverse, fixed_top, fixed_bottom, &active_mask) { + return StitchResult::Failure; + } + let scroll_delta = valid_prev.saturating_sub(best_overlap); if scroll_delta < self.config.min_scroll_threshold { self.last_frame = Some(new_image); return StitchResult::Stationary; } - let prev_seam_start_y = prev_h - fixed_bottom - best_overlap; - let next_seam_start_y = fixed_top; + if is_reverse { + self.valid_height = self.valid_height.saturating_sub(scroll_delta); + self.last_frame = Some(new_image); + self.last_footer_height = fixed_bottom; + return StitchResult::Success; + } - let best_seam_k = self.find_best_seam(last_frame, &new_image, prev_seam_start_y, next_seam_start_y, best_overlap); + let cut_y = self.find_smart_seam(last_frame, &new_image, best_overlap, fixed_top, &active_mask); - let trim_amount = best_overlap.saturating_sub(best_seam_k); - let next_content_start_y = next_seam_start_y + best_seam_k; + let trim_amount = best_overlap.saturating_sub(cut_y); + let next_start = fixed_top + cut_y; - if self.execute_stitch(new_image, trim_amount, next_content_start_y, fixed_bottom) { + if self.execute_stitch(new_image, trim_amount, next_start, fixed_bottom) { StitchResult::Success } else { StitchResult::Failure } } + fn initialize_canvas(&mut self, first_image: RgbaImage) { + let w = first_image.width(); + let h = first_image.height(); + let mut canvas = RgbaImage::new(w, h * 3); + Self::copy_region(&first_image, 0, &mut canvas, 0, h); + + self.canvas = Some(canvas); + self.valid_height = h; + self.last_frame = Some(first_image); + self.last_footer_height = 0; + } + + fn compute_motion_mask(&self, prev: &RgbaImage, next: &RgbaImage) -> Vec { + let w = prev.width(); + let h = prev.height(); + let raw_prev = prev.as_raw(); + let raw_next = next.as_raw(); + let stride = (w * 4) as usize; + let step = self.config.motion_scan_step; + + (0..w) + .step_by(step) + .map(|x| x as usize) + .filter(|&x| { + let mut diff_sum: u64 = 0; + for y in (0..h).step_by(step) { + let idx = (y as usize) * stride + (x * 4); + diff_sum += Self::pixel_diff(raw_prev, idx, raw_next, idx) as u64; + } + diff_sum > (h as u64 / self.config.motion_threshold_divisor) + }) + .collect() + } + + fn compute_masked_signatures(&self, img: &RgbaImage, cols: &[usize]) -> Vec { + let w = img.width(); + let h = img.height(); + let raw = img.as_raw(); + let stride = (w * 4) as usize; + + (0..h) + .map(|y| { + let row_start = (y as usize) * stride; + cols.iter().fold(0u64, |sum, &x| { + let idx = row_start + (x * 4); + sum + Self::pixel_sum(raw, idx) + }) + }) + .collect() + } + + fn measure_fixed_len(&self, s1: &[u64], s2: &[u64], indices: impl Iterator) -> u32 { + let mut len = 0; + for i in indices { + let diff = s1[i].abs_diff(s2[i]); + let max_val = s1[i].max(s2[i]); + if max_val > 0 && (diff * 100 / max_val) > self.config.fixed_diff_percent { + break; + } + len += 1; + } + len + } + + fn detect_fixed_regions_sig(&self, s_prev: &[u64], s_next: &[u64]) -> (u32, u32) { + let h = s_prev.len(); + let max_check = h / 3; + + let top = self.measure_fixed_len(s_prev, s_next, 0..max_check); + let bottom = self.measure_fixed_len(s_prev, s_next, (0..max_check).map(|i| h - 1 - i)); + + (top, bottom) + } + + fn scan_overlaps(&self, range: impl Iterator, calc_offsets: F, s1: &[u64], s2: &[u64]) -> (u32, u64) + where + F: Fn(u32) -> (usize, usize), + { + let mut best_ov = 0; + let mut best_score = u64::MAX; + + for overlap in range { + let (st1, st2) = calc_offsets(overlap); + let score = Self::score_overlap(s1, s2, st1, st2, overlap as usize); + let avg = score / (overlap as u64); + + if avg < self.config.overlap_avg_threshold { + return (overlap, avg); + } + if avg < best_score { + best_score = avg; + best_ov = overlap; + } + } + (best_ov, best_score) + } + + fn find_optimal_overlap(&self, s_prev: &[u64], s_next: &[u64], f_top: u32, f_btm: u32, v_prev: u32, v_next: u32) -> (u32, bool) { + let max_overlap = v_prev.min(v_next); + if max_overlap < self.config.min_overlap { + return (0, false); + } + + let range = (self.config.min_overlap..=max_overlap).rev(); + + let (f_ov, f_score) = self.scan_overlaps( + range.clone(), + |ov| { + let prev_start = (s_prev.len() as u32 - f_btm - ov) as usize; + let next_start = f_top as usize; + (prev_start, next_start) + }, + s_prev, + s_next, + ); + + let (r_ov, r_score) = self.scan_overlaps( + range, + |ov| { + let prev_start = f_top as usize; + let next_start = (s_next.len() as u32 - f_btm - ov) as usize; + (prev_start, next_start) + }, + s_prev, + s_next, + ); + + if r_score < f_score / 2 { (r_ov, true) } else { (f_ov, false) } + } + + #[inline] + fn score_overlap(s1: &[u64], s2: &[u64], start1: usize, start2: usize, len: usize) -> u64 { + (0..len).map(|i| s1[start1 + i].abs_diff(s2[start2 + i])).sum() + } + + fn verify_pixels(&self, prev: &RgbaImage, next: &RgbaImage, overlap: u32, reverse: bool, f_top: u32, f_btm: u32, cols: &[usize]) -> bool { + let raw_prev = prev.as_raw(); + let raw_next = next.as_raw(); + let stride = (prev.width() * 4) as usize; + + let (y1_base, y2_base) = if reverse { + (f_top, next.height() - f_btm - overlap) + } else { + (prev.height() - f_btm - overlap, f_top) + }; + + [0, overlap / 2, overlap - 1].iter().all(|&r| { + let y1 = y1_base + r; + let y2 = y2_base + r; + let step = (cols.len() / self.config.verify_step_divisor).max(1); + + let (hits, checks) = cols.iter().step_by(step).fold((0, 0), |(h, c), &x| { + let idx1 = (y1 as usize) * stride + (x * 4); + let idx2 = (y2 as usize) * stride + (x * 4); + let d = Self::pixel_diff(raw_prev, idx1, raw_next, idx2); + (if d < self.config.verify_pixel_diff { h + 1 } else { h }, c + 1) + }); + + checks == 0 || hits >= (checks / 2) + }) + } + + fn find_smart_seam(&self, prev: &RgbaImage, next: &RgbaImage, overlap: u32, f_top: u32, cols: &[usize]) -> u32 { + let w = prev.width(); + let stride = (w * 4) as usize; + let raw = next.as_raw(); + let start_y = f_top; + + let search_start = if overlap > self.config.min_overlap { + overlap / self.config.seam_margin_divisor + } else { + 0 + }; + let search_end = if overlap > self.config.min_overlap { + overlap * (self.config.seam_margin_divisor - 1) / self.config.seam_margin_divisor + } else { + overlap + }; + let step = (cols.len() / self.config.verify_step_divisor).max(1); + + (search_start..search_end) + .min_by_key(|&k| { + let y = start_y + k; + let idx = (y as usize) * stride; + cols.iter() + .step_by(step) + .map(|&x| { + let p = idx + (x * 4); + if p >= stride { + Self::pixel_diff(raw, p, raw, p - stride) as u64 + } else { + 0 + } + }) + .sum::() + }) + .unwrap_or(overlap / 2) + } + fn execute_stitch(&mut self, new_image: RgbaImage, trim_amount: u32, new_content_start_y: u32, fixed_bottom: u32) -> bool { let canvas = self.canvas.as_mut().unwrap(); - let width = canvas.width(); - let content_end_y = self.valid_height.saturating_sub(self.last_footer_height); if trim_amount > content_end_y { @@ -186,22 +384,19 @@ impl ScrollStitcher { let new_total_h = keep_h + new_content_h; if new_total_h > canvas.height() { - let new_cap = (canvas.height() * 2).max(new_total_h); + let new_cap = (canvas.height() * 2).max(new_total_h + CANVAS_RESIZE_HEADROOM); + let width = canvas.width(); let mut new_canvas = RgbaImage::new(width, new_cap); - Self::copy_region(canvas, 0, &mut new_canvas, 0, keep_h); - self.canvas = Some(new_canvas); } let canvas = self.canvas.as_mut().unwrap(); - Self::copy_region(&new_image, new_content_start_y, canvas, keep_h, new_content_h); self.valid_height = new_total_h; self.last_frame = Some(new_image); self.last_footer_height = fixed_bottom; - true } @@ -209,13 +404,10 @@ impl ScrollStitcher { if height == 0 { return; } - let width_bytes = (src.width() * 4) as usize; let copy_bytes = (height as usize) * width_bytes; - let src_offset = (src_y as usize) * width_bytes; let dest_offset = (dest_y as usize) * width_bytes; - let src_raw = src.as_raw(); let dest_raw = dest.as_mut(); @@ -224,153 +416,13 @@ impl ScrollStitcher { } } - fn detect_fixed_regions(&self, prev: &RgbaImage, next: &RgbaImage) -> (u32, u32) { - let w = prev.width(); - let h = prev.height(); - let max_check = h / 3; - - let raw_prev = prev.as_raw(); - let raw_next = next.as_raw(); - let stride = (w * 4) as usize; - - let mut fixed_top = 0; - for y in 0..max_check { - if !self.rows_match_sampled(raw_prev, raw_next, stride, y, y) { - break; - } - fixed_top += 1; - } - - let mut fixed_bottom = 0; - for y in 0..max_check { - let y_idx = h - 1 - y; - if !self.rows_match_sampled(raw_prev, raw_next, stride, y_idx, y_idx) { - break; - } - fixed_bottom += 1; - } - - (fixed_top, fixed_bottom) - } - - #[inline] - fn rows_match_sampled(&self, raw1: &[u8], raw2: &[u8], stride: usize, y1: u32, y2: u32) -> bool { - let start1 = (y1 as usize) * stride; - let start2 = (y2 as usize) * stride; - - if start1 + stride > raw1.len() || start2 + stride > raw2.len() { - return false; - } - - let r1 = &raw1[start1..start1 + stride]; - let r2 = &raw2[start2..start2 + stride]; - - let mut total_diff: u64 = 0; - let mut count = 0; - - // Sample every 4th pixel (16 bytes) - for (c1, c2) in r1.chunks_exact(16).zip(r2.chunks_exact(16)) { - total_diff += (c1[0].abs_diff(c2[0]) as u64) + (c1[1].abs_diff(c2[1]) as u64) + (c1[2].abs_diff(c2[2]) as u64); - count += 1; - } - - if count == 0 { - return true; - } - (total_diff / count) <= (self.config.pixel_tolerance as u64) - } - - fn compute_row_signatures(&self, img: &RgbaImage, start_y: u32, height: u32) -> Vec { - let w = img.width(); - let stride = (w * 4) as usize; - let raw = img.as_raw(); - let mut sigs = Vec::with_capacity(height as usize); - - for i in 0..height { - let y = start_y + i; - let row_start = (y as usize) * stride; - - if row_start + stride > raw.len() { - sigs.push(0); - continue; - } - - let row = &raw[row_start..row_start + stride]; - let mut sum = 0u64; - - // Sample every 4th pixel for performance (consistent with detection) - // This speeds up signature calculation by 4x without losing much coarse signal - for pixel in row.chunks_exact(16) { - sum += (pixel[0] as u64) + (pixel[1] as u64) + (pixel[2] as u64); - } - sigs.push(sum); - } - sigs + #[inline(always)] + fn pixel_diff(raw1: &[u8], idx1: usize, raw2: &[u8], idx2: usize) -> u32 { + (raw1[idx1].abs_diff(raw2[idx2]) as u32) + (raw1[idx1 + 1].abs_diff(raw2[idx2 + 1]) as u32) + (raw1[idx1 + 2].abs_diff(raw2[idx2 + 2]) as u32) } - fn find_best_overlap(&self, prev_sig: &[u64], next_sig: &[u64]) -> u32 { - let len_prev = prev_sig.len(); - let len_next = next_sig.len(); - let max_overlap = len_prev.min(len_next); - let min_overlap = self.config.min_overlap as usize; - - if max_overlap < min_overlap { - return 0; - } - - let mut best_overlap = 0; - let mut min_avg_diff = u64::MAX; - - // Slide check: BOTTOM of prev matches TOP of next - for overlap in min_overlap..=max_overlap { - let s1_part = &prev_sig[len_prev - overlap..]; - let s2_part = &next_sig[..overlap]; - - let diff_sum: u64 = s1_part.iter().zip(s2_part.iter()).map(|(a, b)| a.abs_diff(*b)).sum(); - - let avg_diff = diff_sum / (overlap as u64); - - if avg_diff < min_avg_diff { - min_avg_diff = avg_diff; - best_overlap = overlap; - } - } - - best_overlap as u32 - } - - fn find_best_seam(&self, prev: &RgbaImage, next: &RgbaImage, prev_y_start: u32, next_y_start: u32, height: u32) -> u32 { - let w = prev.width(); - let stride = (w * 4) as usize; - let raw_prev = prev.as_raw(); - let raw_next = next.as_raw(); - - let mut best_k = 0; - let mut min_row_diff = u64::MAX; - - for k in 0..height { - let idx_prev = ((prev_y_start + k) as usize) * stride; - let idx_next = ((next_y_start + k) as usize) * stride; - - if idx_prev + stride > raw_prev.len() || idx_next + stride > raw_next.len() { - continue; - } - - let r1 = &raw_prev[idx_prev..idx_prev + stride]; - let r2 = &raw_next[idx_next..idx_next + stride]; - - let mut diff: u64 = 0; - // Full pixel difference for precision in seam finding - for (p1, p2) in r1.chunks_exact(4).zip(r2.chunks_exact(4)) { - diff += (p1[0].abs_diff(p2[0]) as u64) + (p1[1].abs_diff(p2[1]) as u64) + (p1[2].abs_diff(p2[2]) as u64); - } - - if diff < min_row_diff { - min_row_diff = diff; - best_k = k; - } - } - - best_k + #[inline(always)] + fn pixel_sum(raw: &[u8], idx: usize) -> u64 { + (raw[idx] as u64) + (raw[idx + 1] as u64) + (raw[idx + 2] as u64) } }