Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- **Natural-language search** – find photos by describing them, e.g. `rclip "two parrots on a branch"`.
- **Reverse / image-to-image search** – search by an example image from a local path or a URL.
- **Combined & arithmetic queries** – mix and weight text and image queries, e.g. `rclip "2:golden retriever" + "./pool.jpg" - fruit`.
- **Non-English queries** – search in your own language, e.g. `rclip "un gato en el sofá"`, with the optional `[translate]` extra.
- **Local & private** – works fully offline; your photos never leave your computer.
- **Wide format support** – `jpg`, `png`, `webp`, `tiff`, `gif`, and more, plus native HEIC on
macOS/Windows and experimental RAW (`arw`, `cr2`, `dng`).
Expand Down Expand Up @@ -137,6 +138,30 @@ cd photos && rclip "./racing car.jpg" - "2:sports car" + "2:snow"

If you want to see how these queries perform when executed on the 1.28 million images ImageNet-1k dataset, check out the demo on YouTube: https://www.youtube.com/watch?v=MsTgYdOpgcQ.

### Can I search using a non-English query?

**rclip**'s underlying AI model only understands English text, but you can search in another
language (Spanish, Japanese, etc.) with the optional `translate` extra installed. The first time,
tell it what language you're typing in with `--lang`:

```bash
cd photos && rclip "un gato en el sofá" --lang es
```

`--lang` downloads a small translation package for that language (a one-time, offline-afterwards
download) and translates the query to English before the search runs. If you don't remember the
code, pass `--lang` with no value to use your system locale's language instead (e.g. `es` on a
system set to `es_ES`); if the code doesn't match any available package, **rclip** suggests close
matches, e.g. `no translation package found for language "esp"; did you mean: es, pt, eo?`.

Once a language's package is installed, you don't need `--lang` again: later queries that aren't
plain ASCII are auto-translated based on your system locale, as long as that locale's language
already has a package installed. If it doesn't, the query just runs as-is (no download, no
warning) -- repeat it with `--lang` once to install the package.

This works well for concrete queries like "cat on the couch", but idioms and figurative language
can lose nuance in translation.

### Which formats does **rclip** support?

**rclip** always indexes the following image formats: `jpg`, `jpeg`, `png`, `webp`, `tiff`, `tif`, `bmp`, `gif`, `jp2`, `pnm`, `pbm`, `pgm`, and `ppm`.
Expand Down Expand Up @@ -222,6 +247,7 @@ Run `rclip --help` (or `rclip -h`) to see this list in your terminal. The positi
| `--include-hidden` | Index dot-prefixed hidden files and directories (e.g. `.DS_Store`, `._IMG_1234.JPG`, `.Spotlight-V100`). Skipped by default since they are usually OS metadata rather than user files. |
| `--experimental-raw-support` | Enable support for RAW images (`arw`, `cr2`, and `dng` are supported). |
| `--max-image-megapixels` `MP` | Maximum size, in megapixels, an image may have to be indexed. Larger images are skipped to avoid running out of memory on huge or maliciously crafted images. Pass `none` to disable the limit. Default: chosen automatically based on the available memory. |
| `--lang` `CODE` | Translate this query to English assuming it's written in `CODE` (ISO 639-1, e.g. `es`), downloading that language's translation package if needed. Bare `--lang` (no `CODE`) uses your system locale's language. Requires the optional `translate` extra. |
| `--version`, `-v` | Print the **rclip** version and exit. |
| `--help`, `-h` | Show the help message and exit. |

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ dependencies = [
"rawpy>=0.26.1,<0.27",
]

[project.optional-dependencies]
translate = ["argostranslate>=1.9,<2"]

[project.urls]
Repository = "https://github.com/yurijmikhalevich/rclip"

Expand All @@ -52,6 +55,7 @@ rclip = "rclip.main:main"

[dependency-groups]
dev = [
"argostranslate>=1.9,<2",
"coremltools>=9.0,<10 ; sys_platform != 'win32'",
"open_clip_torch>=3.2.0,<4",
"pytest>=7.2.1,<10.0",
Expand Down
26 changes: 24 additions & 2 deletions rclip/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import PIL
import PIL.Image

from rclip import db, fs, model
from rclip import db, fs, model, translate
from rclip.const import IMAGE_EXT, IMAGE_RAW_EXT
from rclip.utils.preprocess import preprocess
from rclip.utils.preview import preview
Expand Down Expand Up @@ -321,12 +321,13 @@ def init_rclip(
enable_raw_support: bool = False,
max_image_pixels: helpers.MaxImagePixels = helpers.AUTO_MAX_IMAGE_PIXELS,
include_hidden: bool = False,
forced_lang: Optional[str] = None,
):
datadir = helpers.get_app_datadir()
db_path = datadir / "db.sqlite3"

database = db.DB(db_path, allow_vector_cache_reset=not no_indexing)
model_instance = model.Model()
model_instance = model.Model(forced_lang=forced_lang)
model_instance.ensure_downloaded()
rclip = RClip(
model_instance=model_instance,
Expand Down Expand Up @@ -378,6 +379,26 @@ def main():
if is_snap():
check_snap_permissions(current_directory, is_current_directory=True)

forced_lang = translate.resolve_forced_lang(args.lang)
if args.lang is not None and forced_lang is None:
print(
"rclip: --lang: could not detect a system locale language; pass a language code explicitly, e.g. --lang es",
file=sys.stderr,
)
sys.exit(1)
if forced_lang is not None and forced_lang != "en":
if not translate.is_available():
print(
'rclip: --lang requires the optional "translate" extra; run \'pip install "rclip[translate]"\' to install it',
file=sys.stderr,
)
sys.exit(1)
try:
translate.ensure_language_installed(forced_lang)
except translate.LanguagePackageError as error:
print(error, file=sys.stderr)
sys.exit(1)

rclip, model_instance, db = init_rclip(
current_directory,
args.indexing_batch_size,
Expand All @@ -386,6 +407,7 @@ def main():
args.experimental_raw_support,
args.max_image_megapixels,
args.include_hidden,
forced_lang,
)

try:
Expand Down
6 changes: 4 additions & 2 deletions rclip/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import numpy.typing as npt
from PIL import Image, UnidentifiedImageError

from rclip import model_download
from rclip import model_download, translate
from rclip.utils import helpers
from rclip.utils.preprocess import preprocess
from rclip.utils.tokenizer import SimpleTokenizer
Expand All @@ -21,11 +21,12 @@
class Model:
VECTOR_SIZE = 512

def __init__(self):
def __init__(self, forced_lang: Optional[str] = None):
self._session_text_var: Optional[Any] = None
self._session_visual_var: Optional[Any] = None
self._session_visual_index_var: Optional[Any] = None
self._tokenizer_var: Optional[SimpleTokenizer] = None
self._forced_lang = forced_lang

def ensure_downloaded(self) -> None:
model_download.ensure_downloaded()
Expand Down Expand Up @@ -179,6 +180,7 @@ def compute_features_for_queries(self, queries: List[str]) -> FeatureVector:

if phrases:
phrase_multipliers, phrase_queries = zip(*phrases)
phrase_queries = [translate.translate_to_english(query, self._forced_lang) for query in phrase_queries]
phrase_multipliers_np = np.array(phrase_multipliers).reshape(-1, 1)
text_features = np.add.reduce(self.compute_text_features([*phrase_queries]) * phrase_multipliers_np)

Expand Down
200 changes: 200 additions & 0 deletions rclip/translate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import difflib
import os
import sys
from pathlib import Path
from typing import Optional, Set


class LanguagePackageError(Exception):
"""Raised by ensure_language_installed() when the requested language package can't be
obtained; str(error) is a user-facing message, including "did you mean" suggestions when the
language code doesn't match any package available in the index."""


_translate_libs_available: Optional[bool] = None
_installed_source_languages: Set[str] = set()


def is_available() -> bool:
global _translate_libs_available
if _translate_libs_available is None:
try:
import argostranslate.package # noqa: F401
import argostranslate.translate # noqa: F401

_translate_libs_available = True
except ImportError:
_translate_libs_available = False
return _translate_libs_available


def _is_ascii(text: str) -> bool:
return all(ord(char) < 128 for char in text)


def _get_system_language() -> Optional[str]:
"""Best-effort OS/user locale language code (e.g. "ru" from "ru_RU.UTF-8")."""
import locale

raw = None
for env_var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
raw = os.environ.get(env_var)
if raw:
break
if not raw:
try:
raw = locale.getdefaultlocale()[0]
except Exception:
raw = None
if not raw:
return None
# locale strings look like "ru_RU.UTF-8" or "ru:en"; take the first language subtag
return raw.split(":")[0].split(".")[0].split("_")[0].lower() or None


def resolve_forced_lang(raw: Optional[str]) -> Optional[str]:
"""Resolves the raw "--lang" CLI value. Absent (None) stays None -- queries then only translate
when a package for the system locale's language happens to already be installed. A bare
"--lang" (raw == "") resolves to the system locale's language, so the caller can eagerly
install it. An explicit code (raw == "ru") passes through unchanged."""
if raw is None:
return None
if raw == "":
return _get_system_language()
return raw


def _has_installed_package(lang: str) -> bool:
if lang in _installed_source_languages:
return True
if not is_available():
return False

import argostranslate.package

installed = any(
package.from_code == lang and package.to_code == "en" for package in argostranslate.package.get_installed_packages()
)
if installed:
_installed_source_languages.add(lang)
return installed


def _update_package_index() -> None:
import socket

import argostranslate.package

# argostranslate.package.update_package_index() calls urllib.request.urlopen() with no
# timeout, so a slow/unreachable index host hangs this call forever with no feedback.
previous_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(15)
try:
argostranslate.package.update_package_index()
finally:
socket.setdefaulttimeout(previous_timeout)


def _download_argos_package(url: str, src_lang: str) -> Path:
"""Downloads an .argosmodel package with a visible progress bar (argostranslate's own
Package.download()/install_package_for_language_pair() fetch the whole file silently)."""
import tempfile

import requests
from tqdm import tqdm

response = requests.get(url, stream=True, timeout=60)
response.raise_for_status()
total_bytes = int(response.headers.get("Content-Length", 0))

with tempfile.NamedTemporaryFile(suffix=".argosmodel", delete=False) as tmp_file:
tmp_path = Path(tmp_file.name)
with tqdm(
total=total_bytes or None,
unit="B",
unit_scale=True,
desc=f'Downloading translation package for "{src_lang}"',
) as progress_bar:
for chunk in response.iter_content(chunk_size=1024 * 256):
tmp_file.write(chunk)
progress_bar.update(len(chunk))

return tmp_path


def ensure_language_installed(lang: str) -> None:
"""Downloads and installs the lang -> "en" argos-translate package, unless it's installed
already. Raises LanguagePackageError -- with "did you mean" suggestions when lang doesn't match
any package in the index -- if the package can't be obtained."""
if _has_installed_package(lang):
return

import argostranslate.package

print(f'rclip: fetching translation package index for "{lang}"...', file=sys.stderr)
try:
_update_package_index()
except Exception as error:
raise LanguagePackageError(f'rclip: could not reach the translation package index: {error}') from error

en_targets = [package for package in argostranslate.package.get_available_packages() if package.to_code == "en"]
available_package = next((package for package in en_targets if package.from_code == lang), None)

if available_package is None or not available_package.links:
known_codes = sorted({package.from_code for package in en_targets})
suggestions = difflib.get_close_matches(lang, known_codes, n=3, cutoff=0.4)
hint = f'; did you mean: {", ".join(suggestions)}?' if suggestions else ""
raise LanguagePackageError(f'rclip: no translation package found for language "{lang}"{hint}')

tmp_path = _download_argos_package(available_package.links[0], lang)
try:
argostranslate.package.install_from_path(tmp_path)
finally:
os.remove(tmp_path)
_installed_source_languages.add(lang)


def _as_sentence(text: str) -> str:
"""argos-translate's small NMT models are trained on punctuated sentences and can mangle bare
noun phrases -- dropping or merging words -- which is exactly the kind of short phrase rclip
queries usually are. Capitalizing and terminating the phrase like a full sentence (e.g. turning
the query "gato negro de noche" into "Gato negro de noche." before translating) makes these
translate more reliably."""
stripped = text.strip()
if not stripped:
return stripped
sentence = stripped[0].upper() + stripped[1:]
if sentence[-1] not in ".!?":
sentence += "."
return sentence


def translate_to_english(text: str, forced_lang: Optional[str] = None) -> str:
"""Translates a text query to English so it can be fed into rclip's English-only CLIP model.
forced_lang (set via "--lang") is used as the source language unconditionally, even for
ASCII-only text -- many languages (German, Italian, French, ...) are frequently written without
any non-ASCII characters, and the user has already told us what language this is. Without
forced_lang, the query is translated only when a package for the system locale's language is
already installed (from a previous "--lang" run), so a plain search never triggers a network
call or download; ASCII text is also assumed to already be English in that case, since it can't
be told apart from a same-script foreign phrase without real language detection.
Returns the original text unchanged on English input, missing optional dependencies, an
uninstalled language package, or any translation failure."""
if forced_lang is None and _is_ascii(text):
return text
if not is_available():
return text

src_lang = forced_lang or _get_system_language()
if not src_lang or src_lang == "en":
return text

if not _has_installed_package(src_lang):
return text

import argostranslate.translate

try:
return argostranslate.translate.translate(_as_sentence(text), src_lang, "en")
except Exception:
return text
13 changes: 13 additions & 0 deletions rclip/utils/helpers.py

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! Thank you for proposing this! I was planning to add support for other languages in rclip, but I am not sure if this is the best direction we can take.

To understand this, I'll first need to benchmark this solution (both quality and speed) against:

  • sentence-transformers/clip-ViT-B-32-multilingual-v1
  • siglip-base-patch16-256-i18n

These are both CLIP models, which handle multiple languages natively and are in the same "weight class" as the model rclip uses currently, making them good candidates to solve this problem.

If you can benchmark them, I'd appreciate it and might merge something sooner. Otherwise, I'll decide on the way forward once I've benchmarked them myself.

Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,19 @@ def init_arg_parser() -> argparse.ArgumentParser:
" avoid running out of memory on huge or maliciously crafted images;"
' pass "none" to disable the limit; default: chosen automatically based on available memory',
)
parser.add_argument(
"--lang",
nargs="?",
const="",
default=None,
metavar="CODE",
help="translates non-English text queries to English, forcing the source language to CODE"
' (an ISO 639-1 code, e.g. "es"); downloads the matching translation package on first use;'
' passing "--lang" with no CODE uses your system locale\'s language;'
' once a language\'s package is installed, later queries in it are auto-translated even'
" without this flag, based on your system locale;"
' requires the optional "translate" extra',
)
return parser


Expand Down
Loading