feat: add region-based similarity search
Adds a "Search within image" button to the lightbox that lets the user draw a crop region on an image and find visually similar results using that crop's embedding. The crop is embedded in-memory without a temp file via a new embed_image_crop method on ClipImageEmbedder. Introduces a folder-scoped cosine search (search_image_ids_by_embedding_in_folder) to support the current-folder scope option, and wires up the new find_similar_by_region Tauri command end-to-end from Rust through to the Zustand store and Lightbox UI.
This commit is contained in:
@@ -59,6 +59,19 @@ pub struct FindSimilarImagesParams {
|
|||||||
pub threshold: Option<f32>,
|
pub threshold: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FindSimilarByRegionParams {
|
||||||
|
pub image_id: i64,
|
||||||
|
/// Normalized crop rect (0.0–1.0).
|
||||||
|
pub crop_x: f32,
|
||||||
|
pub crop_y: f32,
|
||||||
|
pub crop_w: f32,
|
||||||
|
pub crop_h: f32,
|
||||||
|
pub folder_id: Option<i64>,
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DebugSimilarImagesParams {
|
pub struct DebugSimilarImagesParams {
|
||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
@@ -354,6 +367,66 @@ pub async fn find_similar_images(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn find_similar_by_region(
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
params: FindSimilarByRegionParams,
|
||||||
|
) -> Result<SimilarImagesPage, String> {
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
let limit = params.limit.unwrap_or(32);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
// Look up the source image path
|
||||||
|
let image = db::get_image_by_id(&conn, params.image_id).map_err(|e| e.to_string())?;
|
||||||
|
let image_path = std::path::Path::new(&image.path);
|
||||||
|
|
||||||
|
// Embed the cropped region in-memory (no temp file needed)
|
||||||
|
let embedder = embedder::ClipImageEmbedder::new().map_err(|e| e.to_string())?;
|
||||||
|
let embedding = embedder
|
||||||
|
.embed_image_crop(
|
||||||
|
image_path,
|
||||||
|
params.crop_x,
|
||||||
|
params.crop_y,
|
||||||
|
params.crop_w,
|
||||||
|
params.crop_h,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Search for similar images using the crop embedding
|
||||||
|
let image_ids = match params.folder_id {
|
||||||
|
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
|
||||||
|
&conn,
|
||||||
|
&embedding,
|
||||||
|
folder_id,
|
||||||
|
Some(params.image_id),
|
||||||
|
offset + limit + 1,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
None => {
|
||||||
|
let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 1)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Exclude the source image from global results
|
||||||
|
ids.retain(|&id| id != params.image_id);
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_more = image_ids.len() > offset + limit;
|
||||||
|
let page_ids = image_ids
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let images = db::get_images_by_ids(&conn, &page_ids).map_err(|e| e.to_string())?;
|
||||||
|
Ok(SimilarImagesPage {
|
||||||
|
images,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct SimilarImagesDebug {
|
pub struct SimilarImagesDebug {
|
||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
|
|||||||
@@ -74,6 +74,51 @@ impl ClipImageEmbedder {
|
|||||||
Ok(self.embed_images(&[path.to_path_buf()])?.remove(0))
|
Ok(self.embed_images(&[path.to_path_buf()])?.remove(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed a cropped region of an image without writing a temp file to disk.
|
||||||
|
/// `crop_x`, `crop_y`, `crop_w`, `crop_h` are normalized 0.0–1.0 coordinates.
|
||||||
|
pub fn embed_image_crop(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
crop_x: f32,
|
||||||
|
crop_y: f32,
|
||||||
|
crop_w: f32,
|
||||||
|
crop_h: f32,
|
||||||
|
) -> Result<Vec<f32>> {
|
||||||
|
let img = image::ImageReader::open(path)?
|
||||||
|
.with_guessed_format()?
|
||||||
|
.decode()?;
|
||||||
|
|
||||||
|
let img_w = img.width() as f32;
|
||||||
|
let img_h = img.height() as f32;
|
||||||
|
|
||||||
|
let x = ((crop_x * img_w) as u32).min(img.width().saturating_sub(1));
|
||||||
|
let y = ((crop_y * img_h) as u32).min(img.height().saturating_sub(1));
|
||||||
|
let w = ((crop_w * img_w) as u32).max(1).min(img.width() - x);
|
||||||
|
let h = ((crop_h * img_h) as u32).max(1).min(img.height() - y);
|
||||||
|
|
||||||
|
let cropped = img.crop_imm(x, y, w, h);
|
||||||
|
let resized = cropped.resize_to_fill(
|
||||||
|
self.image_size as u32,
|
||||||
|
self.image_size as u32,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = resized.to_rgb8().into_raw();
|
||||||
|
let tensor = candle_core::Tensor::from_vec(
|
||||||
|
raw,
|
||||||
|
(self.image_size, self.image_size, 3),
|
||||||
|
&candle_core::Device::Cpu,
|
||||||
|
)?
|
||||||
|
.permute((2, 0, 1))?
|
||||||
|
.to_dtype(candle_core::DType::F32)?
|
||||||
|
.affine(2.0 / 255.0, -1.0)?;
|
||||||
|
|
||||||
|
let batch = tensor.unsqueeze(0)?.to_device(&self.device)?;
|
||||||
|
let features = self.model.get_image_features(&batch)?;
|
||||||
|
let normalized = candle_transformers::models::clip::div_l2_norm(&features)?;
|
||||||
|
Ok(normalized.get(0)?.flatten_all()?.to_vec1::<f32>()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> {
|
pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> {
|
||||||
let images = load_images(paths, self.image_size)?.to_device(&self.device)?;
|
let images = load_images(paths, self.image_size)?.to_device(&self.device)?;
|
||||||
let features = self.model.get_image_features(&images)?;
|
let features = self.model.get_image_features(&images)?;
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub fn run() {
|
|||||||
commands::reindex_folder,
|
commands::reindex_folder,
|
||||||
commands::update_image_details,
|
commands::update_image_details,
|
||||||
commands::find_similar_images,
|
commands::find_similar_images,
|
||||||
|
commands::find_similar_by_region,
|
||||||
commands::debug_similar_images,
|
commands::debug_similar_images,
|
||||||
commands::retry_failed_embeddings,
|
commands::retry_failed_embeddings,
|
||||||
commands::semantic_search_images,
|
commands::semantic_search_images,
|
||||||
|
|||||||
@@ -310,6 +310,40 @@ pub fn search_image_ids_by_embedding(
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brute-force cosine search scoped to a single folder, ordered by ascending distance.
|
||||||
|
/// Used for region-based similarity search where we want folder-scoped results.
|
||||||
|
pub fn search_image_ids_by_embedding_in_folder(
|
||||||
|
conn: &Connection,
|
||||||
|
embedding: &[f32],
|
||||||
|
folder_id: i64,
|
||||||
|
exclude_image_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<i64>> {
|
||||||
|
if embedding.len() != CLIP_VECTOR_DIM {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"expected {}-dimensional embedding, got {}",
|
||||||
|
CLIP_VECTOR_DIM,
|
||||||
|
embedding.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let packed = pack_f32(embedding);
|
||||||
|
let exclude_id = exclude_image_id.unwrap_or(-1);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT v.image_id
|
||||||
|
FROM image_vec v
|
||||||
|
JOIN images i ON i.id = v.image_id
|
||||||
|
WHERE i.folder_id = ?2
|
||||||
|
AND v.image_id != ?3
|
||||||
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
||||||
|
LIMIT ?4",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map((&packed, folder_id, exclude_id, limit as i64), |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn search_caption_ids_by_embedding(
|
pub fn search_caption_ids_by_embedding(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
+241
-8
@@ -58,12 +58,69 @@ function ratingPill(rating: AiRating): { label: string; className: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DragRect {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */
|
||||||
|
function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } {
|
||||||
|
return {
|
||||||
|
left: Math.min(r.startX, r.endX),
|
||||||
|
top: Math.min(r.startY, r.endY),
|
||||||
|
width: Math.abs(r.endX - r.startX),
|
||||||
|
height: Math.abs(r.endY - r.startY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 0–1 crop coords
|
||||||
|
* relative to the actual rendered <img> element bounds. */
|
||||||
|
function rectToNormalisedCrop(
|
||||||
|
rect: DragRect,
|
||||||
|
imgEl: HTMLImageElement,
|
||||||
|
): { x: number; y: number; w: number; h: number } | null {
|
||||||
|
const imgBounds = imgEl.getBoundingClientRect();
|
||||||
|
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
|
||||||
|
|
||||||
|
// rect coords are already in viewport space (client coords)
|
||||||
|
const rawX = Math.min(rect.startX, rect.endX);
|
||||||
|
const rawY = Math.min(rect.startY, rect.endY);
|
||||||
|
const rawW = Math.abs(rect.endX - rect.startX);
|
||||||
|
const rawH = Math.abs(rect.endY - rect.startY);
|
||||||
|
|
||||||
|
// Clamp to image bounds
|
||||||
|
const clampedX = Math.max(rawX, imgBounds.left);
|
||||||
|
const clampedY = Math.max(rawY, imgBounds.top);
|
||||||
|
const clampedRight = Math.min(rawX + rawW, imgBounds.right);
|
||||||
|
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom);
|
||||||
|
|
||||||
|
const croppedW = clampedRight - clampedX;
|
||||||
|
const croppedH = clampedBottom - clampedY;
|
||||||
|
|
||||||
|
if (croppedW <= 0 || croppedH <= 0) return null;
|
||||||
|
|
||||||
|
// Normalize by the CSS transform scale — getBoundingClientRect already returns
|
||||||
|
// the scaled (on-screen) size, so we normalize directly against that.
|
||||||
|
return {
|
||||||
|
x: (clampedX - imgBounds.left) / imgBounds.width,
|
||||||
|
y: (clampedY - imgBounds.top) / imgBounds.height,
|
||||||
|
w: croppedW / imgBounds.width,
|
||||||
|
h: croppedH / imgBounds.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum selection size as a fraction of the viewport container dimension. */
|
||||||
|
const MIN_SELECTION_FRACTION = 0.02;
|
||||||
|
|
||||||
export function Lightbox() {
|
export function Lightbox() {
|
||||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||||
const images = useGalleryStore((state) => state.images);
|
const images = useGalleryStore((state) => state.images);
|
||||||
const openImage = useGalleryStore((state) => state.openImage);
|
const openImage = useGalleryStore((state) => state.openImage);
|
||||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||||
|
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
|
||||||
const similarScope = useGalleryStore((state) => state.similarScope);
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||||
const getImageTags = useGalleryStore((state) => state.getImageTags);
|
const getImageTags = useGalleryStore((state) => state.getImageTags);
|
||||||
@@ -71,16 +128,26 @@ export function Lightbox() {
|
|||||||
const removeTag = useGalleryStore((state) => state.removeTag);
|
const removeTag = useGalleryStore((state) => state.removeTag);
|
||||||
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||||
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
const [tagAdding, setTagAdding] = useState(false);
|
const [tagAdding, setTagAdding] = useState(false);
|
||||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||||
const [taggingQueued, setTaggingQueued] = useState(false);
|
const [taggingQueued, setTaggingQueued] = useState(false);
|
||||||
|
|
||||||
|
// Region selection state
|
||||||
|
const [regionSelectMode, setRegionSelectMode] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragRect, setDragRect] = useState<DragRect | null>(null);
|
||||||
|
const [regionSearching, setRegionSearching] = useState(false);
|
||||||
|
|
||||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
||||||
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
||||||
|
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
if (currentIndex > 0) openImage(images[currentIndex - 1]);
|
if (currentIndex > 0) openImage(images[currentIndex - 1]);
|
||||||
@@ -90,13 +157,21 @@ export function Lightbox() {
|
|||||||
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
|
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
|
||||||
}, [currentIndex, images, openImage]);
|
}, [currentIndex, images, openImage]);
|
||||||
|
|
||||||
|
const exitRegionMode = useCallback(() => {
|
||||||
|
setRegionSelectMode(false);
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setImageTags([]);
|
setImageTags([]);
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
setTagsExpanded(false);
|
setTagsExpanded(false);
|
||||||
setTaggingQueued(false);
|
setTaggingQueued(false);
|
||||||
}, [selectedImage?.id]);
|
exitRegionMode();
|
||||||
|
setRegionSearching(false);
|
||||||
|
}, [selectedImage?.id, exitRegionMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedImage) return;
|
if (!selectedImage) return;
|
||||||
@@ -115,6 +190,7 @@ export function Lightbox() {
|
|||||||
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (regionSelectMode) return; // don't zoom during selection
|
||||||
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setZoom((value) => {
|
setZoom((value) => {
|
||||||
@@ -125,12 +201,19 @@ export function Lightbox() {
|
|||||||
|
|
||||||
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
return () => viewport.removeEventListener("wheel", handleWheel);
|
return () => viewport.removeEventListener("wheel", handleWheel);
|
||||||
}, [selectedImage]);
|
}, [selectedImage, regionSelectMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
if (!selectedImage) return;
|
if (!selectedImage) return;
|
||||||
if (event.key === "Escape") closeImage();
|
if (event.key === "Escape") {
|
||||||
|
if (regionSelectMode) {
|
||||||
|
exitRegionMode();
|
||||||
|
} else {
|
||||||
|
closeImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (regionSelectMode) return; // block nav keys during selection
|
||||||
if (event.key === "ArrowLeft") goPrev();
|
if (event.key === "ArrowLeft") goPrev();
|
||||||
if (event.key === "ArrowRight") goNext();
|
if (event.key === "ArrowRight") goNext();
|
||||||
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
||||||
@@ -139,7 +222,75 @@ export function Lightbox() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [selectedImage, closeImage, goPrev, goNext]);
|
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]);
|
||||||
|
|
||||||
|
// ── Region selection pointer handlers ───────────────────────────────────────
|
||||||
|
const handleRegionPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!regionSelectMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragRect({
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
endX: event.clientX,
|
||||||
|
endY: event.clientY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[regionSelectMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegionPointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
setDragRect((prev) =>
|
||||||
|
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isDragging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegionPointerUp = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
|
||||||
|
setIsDragging(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY };
|
||||||
|
const crop = rectToNormalisedCrop(finalRect, imgRef.current);
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragRect(null);
|
||||||
|
|
||||||
|
// Ignore tiny accidental clicks
|
||||||
|
const containerBounds = imageViewportRef.current?.getBoundingClientRect();
|
||||||
|
const containerSize = containerBounds
|
||||||
|
? Math.min(containerBounds.width, containerBounds.height)
|
||||||
|
: 500;
|
||||||
|
const selW = Math.abs(finalRect.endX - finalRect.startX);
|
||||||
|
const selH = Math.abs(finalRect.endY - finalRect.startY);
|
||||||
|
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) {
|
||||||
|
exitRegionMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitRegionMode();
|
||||||
|
setRegionSearching(true);
|
||||||
|
|
||||||
|
const folderId =
|
||||||
|
similarScope === "current_folder" ? selectedImage.folder_id : null;
|
||||||
|
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
|
||||||
|
.finally(() => setRegionSearching(false));
|
||||||
|
},
|
||||||
|
[isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the CSS rect for the selection overlay (viewport-relative)
|
||||||
|
const selectionOverlay =
|
||||||
|
isDragging && dragRect ? normaliseRect(dragRect) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -151,11 +302,11 @@ export function Lightbox() {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
onClick={closeImage}
|
onClick={regionSelectMode ? undefined : closeImage}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||||
disabled={currentIndex <= 0}
|
disabled={currentIndex <= 0 || regionSelectMode}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
goPrev();
|
goPrev();
|
||||||
@@ -170,8 +321,38 @@ export function Lightbox() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={imageViewportRef}
|
ref={imageViewportRef}
|
||||||
className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
|
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${
|
||||||
|
regionSelectMode ? "cursor-crosshair select-none" : ""
|
||||||
|
}`}
|
||||||
|
onPointerDown={handleRegionPointerDown}
|
||||||
|
onPointerMove={handleRegionPointerMove}
|
||||||
|
onPointerUp={handleRegionPointerUp}
|
||||||
>
|
>
|
||||||
|
{/* Region selection mode hint */}
|
||||||
|
{regionSelectMode && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
|
||||||
|
<svg className="h-3.5 w-3.5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
Draw a region to search — <kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd> to cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection rectangle overlay */}
|
||||||
|
{selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-30 rounded border-2 border-violet-400 bg-violet-400/15 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
|
||||||
|
style={{
|
||||||
|
left: selectionOverlay.left,
|
||||||
|
top: selectionOverlay.top,
|
||||||
|
width: selectionOverlay.width,
|
||||||
|
height: selectionOverlay.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={selectedImage.id}
|
key={selectedImage.id}
|
||||||
@@ -191,6 +372,7 @@ export function Lightbox() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={convertFileSrc(selectedImage.path)}
|
src={convertFileSrc(selectedImage.path)}
|
||||||
alt={selectedImage.filename}
|
alt={selectedImage.filename}
|
||||||
className="max-w-full rounded-2xl shadow-2xl"
|
className="max-w-full rounded-2xl shadow-2xl"
|
||||||
@@ -198,8 +380,11 @@ export function Lightbox() {
|
|||||||
maxHeight: "calc(100vh - 10rem)",
|
maxHeight: "calc(100vh - 10rem)",
|
||||||
transform: `scale(${zoom})`,
|
transform: `scale(${zoom})`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
|
// Slightly dim the image while in region select mode
|
||||||
|
...(regionSelectMode ? { opacity: 0.85 } : {}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{!regionSelectMode && (
|
||||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||||
<button
|
<button
|
||||||
@@ -217,6 +402,7 @@ export function Lightbox() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -261,6 +447,53 @@ export function Lightbox() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search region button row */}
|
||||||
|
{canSearchRegion && (
|
||||||
|
<div className="shrink-0 px-5 pb-3">
|
||||||
|
<button
|
||||||
|
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||||
|
regionSelectMode
|
||||||
|
? "border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20"
|
||||||
|
: regionSearching
|
||||||
|
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed"
|
||||||
|
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (regionSearching) return;
|
||||||
|
setRegionSelectMode((prev) => !prev);
|
||||||
|
setDragRect(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
disabled={regionSearching}
|
||||||
|
title={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"}
|
||||||
|
>
|
||||||
|
{regionSearching ? (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
Searching region…
|
||||||
|
</span>
|
||||||
|
) : regionSelectMode ? (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Cancel selection
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
Search within image
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
||||||
@@ -472,7 +705,7 @@ export function Lightbox() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||||
disabled={currentIndex >= images.length - 1}
|
disabled={currentIndex >= images.length - 1 || regionSelectMode}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
goNext();
|
goNext();
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ interface GalleryState {
|
|||||||
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
||||||
searchForTag: (tag: string) => void;
|
searchForTag: (tag: string) => void;
|
||||||
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
|
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
|
||||||
|
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null) => Promise<void>;
|
||||||
setSimilarScope: (scope: SimilarScope) => void;
|
setSimilarScope: (scope: SimilarScope) => void;
|
||||||
suggestImageTags: (imageId: number) => Promise<string[]>;
|
suggestImageTags: (imageId: number) => Promise<string[]>;
|
||||||
loadCaptionModelStatus: () => Promise<void>;
|
loadCaptionModelStatus: () => Promise<void>;
|
||||||
@@ -992,6 +993,72 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null) => {
|
||||||
|
const requestToken = ++similarRequestToken;
|
||||||
|
const similarScope = folderId === null ? "all_media" : "current_folder";
|
||||||
|
set((state) => ({
|
||||||
|
images: [],
|
||||||
|
loadedCount: 0,
|
||||||
|
loadingImages: true,
|
||||||
|
collectionTitle: "Region Search Results",
|
||||||
|
imageLoadError: null,
|
||||||
|
similarSourceImageId: imageId,
|
||||||
|
similarSourceFolderId: sourceFolderId,
|
||||||
|
similarFolderId: folderId ?? null,
|
||||||
|
similarScope,
|
||||||
|
galleryScrollResetKey: state.galleryScrollResetKey + 1,
|
||||||
|
selectedImage: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<SimilarImagesPage>("find_similar_by_region", {
|
||||||
|
params: {
|
||||||
|
image_id: imageId,
|
||||||
|
crop_x: crop.x,
|
||||||
|
crop_y: crop.y,
|
||||||
|
crop_w: crop.w,
|
||||||
|
crop_h: crop.h,
|
||||||
|
folder_id: folderId ?? null,
|
||||||
|
offset: 0,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToken !== similarRequestToken) return;
|
||||||
|
|
||||||
|
set({
|
||||||
|
images: result.images,
|
||||||
|
totalImages: result.has_more ? result.images.length + 1 : result.images.length,
|
||||||
|
loadedCount: result.images.length,
|
||||||
|
loadingImages: false,
|
||||||
|
imageLoadError: null,
|
||||||
|
collectionTitle: "Region Search Results",
|
||||||
|
similarSourceImageId: imageId,
|
||||||
|
similarSourceFolderId: sourceFolderId,
|
||||||
|
similarHasMore: result.has_more,
|
||||||
|
similarFolderId: folderId ?? null,
|
||||||
|
similarScope,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (requestToken !== similarRequestToken) return;
|
||||||
|
console.error("Failed to load region search results:", error);
|
||||||
|
set({
|
||||||
|
images: [],
|
||||||
|
totalImages: 0,
|
||||||
|
loadedCount: 0,
|
||||||
|
loadingImages: false,
|
||||||
|
imageLoadError: String(error),
|
||||||
|
collectionTitle: "Region Search Results",
|
||||||
|
similarSourceImageId: imageId,
|
||||||
|
similarSourceFolderId: sourceFolderId,
|
||||||
|
similarHasMore: false,
|
||||||
|
similarFolderId: folderId ?? null,
|
||||||
|
similarScope,
|
||||||
|
selectedImage: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setSimilarScope: (similarScope) => {
|
setSimilarScope: (similarScope) => {
|
||||||
set({ similarScope });
|
set({ similarScope });
|
||||||
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
|
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user