clens-cli 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clens/__init__.py +0 -0
- clens/app_config.py +29 -0
- clens/cli.py +389 -0
- clens/command_handlers.py +322 -0
- clens/data_schemas.py +29 -0
- clens/data_store.py +56 -0
- clens/dir_crawler.py +18 -0
- clens/embedder.py +74 -0
- clens/indexer.py +20 -0
- clens/paths.py +22 -0
- clens/runtime.py +31 -0
- clens/search.py +25 -0
- clens/server.py +100 -0
- clens_cli-1.0.1.dist-info/METADATA +210 -0
- clens_cli-1.0.1.dist-info/RECORD +17 -0
- clens_cli-1.0.1.dist-info/WHEEL +4 -0
- clens_cli-1.0.1.dist-info/entry_points.txt +3 -0
clens/__init__.py
ADDED
|
File without changes
|
clens/app_config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from clens.paths import CONFIG_FILE
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UserPreferences(BaseModel):
|
|
8
|
+
default_host: str = "127.0.0.1"
|
|
9
|
+
default_port: int = 6969
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppConfig(BaseModel):
|
|
13
|
+
preferences: UserPreferences = Field(default_factory=UserPreferences)
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def load_from_file(cls, path: Path = CONFIG_FILE) -> "AppConfig":
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return cls()
|
|
19
|
+
try:
|
|
20
|
+
with open(path, "r") as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
return cls.model_validate(data)
|
|
23
|
+
except Exception:
|
|
24
|
+
return cls()
|
|
25
|
+
|
|
26
|
+
def save_to_file(self, path: Path = CONFIG_FILE) -> None:
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with open(path, "w") as f:
|
|
29
|
+
f.write(self.model_dump_json(indent=2))
|
clens/cli.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
4
|
+
os.environ.setdefault("HF_HUB_VERBOSITY", "error")
|
|
5
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
6
|
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from clens.app_config import AppConfig
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import override
|
|
13
|
+
import logging
|
|
14
|
+
from clens.paths import CONFIG_FILE, RUNTIME_FILE
|
|
15
|
+
from clens.runtime import RuntimeState
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClensCLIParser(argparse.ArgumentParser):
|
|
20
|
+
COMMANDS = {
|
|
21
|
+
"server": {
|
|
22
|
+
"desc": "Manage the clens server",
|
|
23
|
+
"arguments": {
|
|
24
|
+
"start": {
|
|
25
|
+
"desc": "Start the clens server",
|
|
26
|
+
"arguments": {},
|
|
27
|
+
"options": {
|
|
28
|
+
"-H, --host": "Host to start the server on",
|
|
29
|
+
"-p, --port": "Port to start the server on",
|
|
30
|
+
},
|
|
31
|
+
"flags": {
|
|
32
|
+
"-d, --detach": "Detach server from the current shell session",
|
|
33
|
+
},
|
|
34
|
+
"req_server": False,
|
|
35
|
+
},
|
|
36
|
+
"stop": {
|
|
37
|
+
"desc": "Stop the clens server",
|
|
38
|
+
"arguments": {},
|
|
39
|
+
"options": {},
|
|
40
|
+
"flags": {},
|
|
41
|
+
"req_server": True,
|
|
42
|
+
},
|
|
43
|
+
"status": {
|
|
44
|
+
"desc": "Get the status of the clens server",
|
|
45
|
+
"arguments": {},
|
|
46
|
+
"options": {},
|
|
47
|
+
"flags": {},
|
|
48
|
+
"req_server": False,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"options": {},
|
|
52
|
+
"flags": {},
|
|
53
|
+
"req_server": False,
|
|
54
|
+
},
|
|
55
|
+
"index": {
|
|
56
|
+
"desc": "Index a given directory of images",
|
|
57
|
+
"arguments": {
|
|
58
|
+
"path": {
|
|
59
|
+
"desc": "Directory of images to index",
|
|
60
|
+
"arguments": {},
|
|
61
|
+
"options": {},
|
|
62
|
+
"flags": {},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"options": {},
|
|
66
|
+
"flags": {},
|
|
67
|
+
"req_server": True,
|
|
68
|
+
},
|
|
69
|
+
"find": {
|
|
70
|
+
"desc": "Find an image from the indexed directories",
|
|
71
|
+
"arguments": {
|
|
72
|
+
"query_term": {
|
|
73
|
+
"desc": "Query term to search for",
|
|
74
|
+
"arguments": {},
|
|
75
|
+
"options": {},
|
|
76
|
+
"flags": {},
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"options": {"-c, --count": "Number of images to find (default: 5)"},
|
|
80
|
+
"flags": {"-p, --plain": "Output plain tab-seperated result"},
|
|
81
|
+
"req_server": True,
|
|
82
|
+
},
|
|
83
|
+
"match": {
|
|
84
|
+
"desc": "Match an image from the indexed directories",
|
|
85
|
+
"arguments": {
|
|
86
|
+
"image_path": {
|
|
87
|
+
"desc": "Image to find matches for",
|
|
88
|
+
"arguments": {},
|
|
89
|
+
"options": {},
|
|
90
|
+
"flags": {},
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"options": {"-c, --count": "Numbers of images to find (default: 5)"},
|
|
94
|
+
"flags": {"-p, --plain": "Output plain tab-seperated result"},
|
|
95
|
+
"req_server": True,
|
|
96
|
+
},
|
|
97
|
+
"version": {
|
|
98
|
+
"desc": "Print the current clens version",
|
|
99
|
+
"arguments": {},
|
|
100
|
+
"options": {},
|
|
101
|
+
"flags": {},
|
|
102
|
+
"req_server": False,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def __init__(self, *args, custom_help: str | None = None, **kwargs):
|
|
107
|
+
super().__init__(*args, **kwargs)
|
|
108
|
+
self.custom_help = custom_help
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
def format_help(self) -> str:
|
|
112
|
+
if self.custom_help:
|
|
113
|
+
return self.custom_help
|
|
114
|
+
if self.prog != "clens":
|
|
115
|
+
return super().format_help()
|
|
116
|
+
command_desc = ""
|
|
117
|
+
for command in self.COMMANDS.keys():
|
|
118
|
+
command_desc += f" {command}: {self.COMMANDS[command]['desc']}\n"
|
|
119
|
+
|
|
120
|
+
return f"""
|
|
121
|
+
clens: Local semantic image search tool
|
|
122
|
+
|
|
123
|
+
Description:
|
|
124
|
+
Clens is a semantic image search tool that allows users
|
|
125
|
+
to index a given directory of images and search through it
|
|
126
|
+
using natural language, search for matching images using
|
|
127
|
+
another image etc. locally on their device.
|
|
128
|
+
|
|
129
|
+
Usage:
|
|
130
|
+
clens [COMMAND] <ARGUMENT> <...options>
|
|
131
|
+
clens [COMMAND] --help
|
|
132
|
+
|
|
133
|
+
Commands:
|
|
134
|
+
{command_desc}
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
clens server start --port 6969 --detach
|
|
138
|
+
|
|
139
|
+
clens index ./Pictures
|
|
140
|
+
|
|
141
|
+
clens find "a dog with a hat"
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
logging.basicConfig(
|
|
147
|
+
level=logging.INFO,
|
|
148
|
+
format=" [%(levelname)s] %(name)s: %(message)s",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
logger = logging.getLogger(__name__)
|
|
152
|
+
runtime_state = RuntimeState().load_runtime()
|
|
153
|
+
|
|
154
|
+
config = AppConfig.load_from_file(CONFIG_FILE)
|
|
155
|
+
config.save_to_file()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cli():
|
|
159
|
+
|
|
160
|
+
clens_parser = ClensCLIParser(prog="clens", add_help=True)
|
|
161
|
+
commands = clens_parser.add_subparsers(dest="command")
|
|
162
|
+
|
|
163
|
+
# Server sub command
|
|
164
|
+
server_parser = commands.add_parser(
|
|
165
|
+
"server",
|
|
166
|
+
custom_help=format_custom_help(
|
|
167
|
+
"clens server", ClensCLIParser.COMMANDS["server"]
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
server_commands = server_parser.add_subparsers(dest="server_action")
|
|
171
|
+
|
|
172
|
+
server_start_parser = server_commands.add_parser(
|
|
173
|
+
"start",
|
|
174
|
+
custom_help=format_custom_help(
|
|
175
|
+
"clens server start",
|
|
176
|
+
ClensCLIParser.COMMANDS["server"]["arguments"]["start"],
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
server_start_parser.add_argument(
|
|
180
|
+
"-H",
|
|
181
|
+
"--host",
|
|
182
|
+
type=str,
|
|
183
|
+
default=config.preferences.default_host,
|
|
184
|
+
)
|
|
185
|
+
server_start_parser.add_argument(
|
|
186
|
+
"-p",
|
|
187
|
+
"--port",
|
|
188
|
+
type=int,
|
|
189
|
+
default=config.preferences.default_port,
|
|
190
|
+
)
|
|
191
|
+
server_start_parser.add_argument(
|
|
192
|
+
"-d",
|
|
193
|
+
"--detach",
|
|
194
|
+
action="store_true",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
server_commands.add_parser(
|
|
198
|
+
"stop",
|
|
199
|
+
custom_help=format_custom_help(
|
|
200
|
+
"clens server stop",
|
|
201
|
+
ClensCLIParser.COMMANDS["server"]["arguments"]["stop"],
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
server_commands.add_parser(
|
|
205
|
+
"status",
|
|
206
|
+
custom_help=format_custom_help(
|
|
207
|
+
"clens server status",
|
|
208
|
+
ClensCLIParser.COMMANDS["server"]["arguments"]["status"],
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Index sub command
|
|
213
|
+
index_parser = commands.add_parser(
|
|
214
|
+
"index",
|
|
215
|
+
custom_help=format_custom_help("clens index", ClensCLIParser.COMMANDS["index"]),
|
|
216
|
+
)
|
|
217
|
+
index_parser.add_argument(
|
|
218
|
+
"path",
|
|
219
|
+
nargs="?",
|
|
220
|
+
default=None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Find sub command
|
|
224
|
+
find_parser = commands.add_parser(
|
|
225
|
+
"find",
|
|
226
|
+
custom_help=format_custom_help("clens find", ClensCLIParser.COMMANDS["find"]),
|
|
227
|
+
)
|
|
228
|
+
find_parser.add_argument(
|
|
229
|
+
"query_term",
|
|
230
|
+
nargs="?",
|
|
231
|
+
default=None,
|
|
232
|
+
)
|
|
233
|
+
find_parser.add_argument(
|
|
234
|
+
"-p",
|
|
235
|
+
"--plain",
|
|
236
|
+
action="store_true",
|
|
237
|
+
)
|
|
238
|
+
find_parser.add_argument(
|
|
239
|
+
"-c",
|
|
240
|
+
"--count",
|
|
241
|
+
type=int,
|
|
242
|
+
default=5,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Match sub command
|
|
246
|
+
match_parser = commands.add_parser(
|
|
247
|
+
"match",
|
|
248
|
+
custom_help=format_custom_help("clens match", ClensCLIParser.COMMANDS["match"]),
|
|
249
|
+
)
|
|
250
|
+
match_parser.add_argument(
|
|
251
|
+
"image_path",
|
|
252
|
+
nargs="?",
|
|
253
|
+
default=None,
|
|
254
|
+
)
|
|
255
|
+
match_parser.add_argument(
|
|
256
|
+
"-c",
|
|
257
|
+
"--count",
|
|
258
|
+
type=int,
|
|
259
|
+
default=5,
|
|
260
|
+
)
|
|
261
|
+
match_parser.add_argument(
|
|
262
|
+
"-p",
|
|
263
|
+
"--plain",
|
|
264
|
+
action="store_true",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Version sub command
|
|
268
|
+
version_parser = commands.add_parser(
|
|
269
|
+
"version",
|
|
270
|
+
custom_help=format_custom_help(
|
|
271
|
+
"clens version", ClensCLIParser.COMMANDS["version"]
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
args = clens_parser.parse_args()
|
|
276
|
+
|
|
277
|
+
if args.command is not None and clens_parser.COMMANDS[args.command]["req_server"]:
|
|
278
|
+
if not RUNTIME_FILE.exists():
|
|
279
|
+
logger.error("Clens server is not running!")
|
|
280
|
+
logger.info("Start the server first with: clens server start")
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
match args.command:
|
|
284
|
+
case "server":
|
|
285
|
+
match args.server_action:
|
|
286
|
+
case "start":
|
|
287
|
+
from clens.command_handlers import cmd_start_server
|
|
288
|
+
|
|
289
|
+
cmd_start_server(args.host, args.port, args.detach)
|
|
290
|
+
case "stop":
|
|
291
|
+
from clens.command_handlers import cmd_stop_server
|
|
292
|
+
|
|
293
|
+
cmd_stop_server()
|
|
294
|
+
case "status":
|
|
295
|
+
from clens.command_handlers import cmd_server_status
|
|
296
|
+
|
|
297
|
+
cmd_server_status()
|
|
298
|
+
case _:
|
|
299
|
+
server_parser.print_help()
|
|
300
|
+
sys.exit(0)
|
|
301
|
+
|
|
302
|
+
case "index":
|
|
303
|
+
if args.path is None:
|
|
304
|
+
index_parser.print_help()
|
|
305
|
+
sys.exit(0)
|
|
306
|
+
from clens.command_handlers import cmd_index_images
|
|
307
|
+
|
|
308
|
+
cmd_index_images(args.path)
|
|
309
|
+
case "find":
|
|
310
|
+
if args.query_term is None:
|
|
311
|
+
find_parser.print_help()
|
|
312
|
+
sys.exit(0)
|
|
313
|
+
from clens.command_handlers import cmd_find_images
|
|
314
|
+
|
|
315
|
+
cmd_find_images(args.query_term, args.count, args.plain)
|
|
316
|
+
case "match":
|
|
317
|
+
if args.image_path is None:
|
|
318
|
+
match_parser.print_help()
|
|
319
|
+
sys.exit(0)
|
|
320
|
+
from clens.command_handlers import cmd_match_image
|
|
321
|
+
|
|
322
|
+
cmd_match_image(args.image_path, args.count, args.plain)
|
|
323
|
+
case "version":
|
|
324
|
+
print(f"clens v{version('clens-cli')}")
|
|
325
|
+
case _:
|
|
326
|
+
clens_parser.print_help()
|
|
327
|
+
sys.exit(0)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def format_custom_help(prog: str, cmd_data: dict) -> str:
|
|
331
|
+
prog_name = prog if prog.startswith("clens") else f"clens {prog}"
|
|
332
|
+
desc = cmd_data.get("desc", "")
|
|
333
|
+
args = cmd_data.get("arguments", {})
|
|
334
|
+
options = cmd_data.get("options", {})
|
|
335
|
+
flags = cmd_data.get("flags", {})
|
|
336
|
+
|
|
337
|
+
usage_parts = [prog_name, "[-h]"]
|
|
338
|
+
|
|
339
|
+
for flag_key in flags.keys():
|
|
340
|
+
short = flag_key.split(",")[0].strip()
|
|
341
|
+
usage_parts.append(f"[{short}]")
|
|
342
|
+
|
|
343
|
+
for opt_key in options.keys():
|
|
344
|
+
parts = [p.strip() for p in opt_key.split(",")]
|
|
345
|
+
short = parts[0]
|
|
346
|
+
val_name = parts[-1].lstrip("-").upper()
|
|
347
|
+
usage_parts.append(f"[{short} {val_name}]")
|
|
348
|
+
|
|
349
|
+
has_subcommands = len(args) > 1 or any(
|
|
350
|
+
isinstance(v, dict)
|
|
351
|
+
and (v.get("options") or v.get("flags") or v.get("arguments"))
|
|
352
|
+
for v in args.values()
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if args:
|
|
356
|
+
if has_subcommands:
|
|
357
|
+
usage_parts.append(f"{{{','.join(args.keys())}}}")
|
|
358
|
+
else:
|
|
359
|
+
usage_parts.extend(f"<{k}>" for k in args.keys())
|
|
360
|
+
|
|
361
|
+
sections = [
|
|
362
|
+
f"\n{prog_name}: {desc}\n",
|
|
363
|
+
f"Usage:\n {' '.join(usage_parts)}\n",
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
if args:
|
|
367
|
+
header = "Commands:" if has_subcommands else "Arguments:"
|
|
368
|
+
lines = [f"{header}"]
|
|
369
|
+
max_w = max(len(k) for k in args.keys())
|
|
370
|
+
for k, v in args.items():
|
|
371
|
+
arg_desc = v.get("desc", "") if isinstance(v, dict) else str(v)
|
|
372
|
+
lines.append(f" {k:<{max_w}} {arg_desc}")
|
|
373
|
+
sections.append("\n".join(lines) + "\n")
|
|
374
|
+
|
|
375
|
+
if options:
|
|
376
|
+
lines = ["Options:"]
|
|
377
|
+
max_w = max(len(k) for k in options.keys())
|
|
378
|
+
for k, v in options.items():
|
|
379
|
+
lines.append(f" {k:<{max_w}} {v}")
|
|
380
|
+
sections.append("\n".join(lines) + "\n")
|
|
381
|
+
|
|
382
|
+
all_flags = {**flags, "-h, --help": "Show this help message and exit\n"}
|
|
383
|
+
lines = ["Flags:"]
|
|
384
|
+
max_w = max(len(k) for k in all_flags.keys())
|
|
385
|
+
for k, v in all_flags.items():
|
|
386
|
+
lines.append(f" {k:<{max_w}} {v}")
|
|
387
|
+
sections.append("\n".join(lines) + "\n")
|
|
388
|
+
|
|
389
|
+
return "\n".join(sections)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import signal
|
|
2
|
+
import requests
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from clens.data_schemas import ResponseBody
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
from urllib.parse import urljoin, quote
|
|
12
|
+
from clens.paths import RUNTIME_FILE, LOG_FILE
|
|
13
|
+
from clens.cli import runtime_state
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_start_server(host: str, port: int, detach: bool):
|
|
19
|
+
|
|
20
|
+
if RUNTIME_FILE.exists() and runtime_state.pid is not None:
|
|
21
|
+
logger.warning("Clens server is already running!")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
os.environ["CLENS_RUNTIME_HOST"] = host
|
|
25
|
+
os.environ["CLENS_RUNTIME_PORT"] = str(port)
|
|
26
|
+
|
|
27
|
+
if not detach:
|
|
28
|
+
try:
|
|
29
|
+
logger.info(f"Server running at http://{host}:{port}")
|
|
30
|
+
import uvicorn
|
|
31
|
+
|
|
32
|
+
uvicorn.run(
|
|
33
|
+
"clens.server:clens_server",
|
|
34
|
+
host=host,
|
|
35
|
+
port=port,
|
|
36
|
+
log_level=logging.INFO,
|
|
37
|
+
access_log=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
finally:
|
|
41
|
+
logger.info(f"Server shutting down....")
|
|
42
|
+
logger.info(f"Server shutdown successfully.")
|
|
43
|
+
else:
|
|
44
|
+
logger.info(f"Starting clens server...")
|
|
45
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
output = open(LOG_FILE, "a")
|
|
48
|
+
|
|
49
|
+
start_cmd = [
|
|
50
|
+
sys.executable,
|
|
51
|
+
"-m",
|
|
52
|
+
"uvicorn",
|
|
53
|
+
"clens.server:clens_server",
|
|
54
|
+
"--host",
|
|
55
|
+
host,
|
|
56
|
+
"--port",
|
|
57
|
+
str(port),
|
|
58
|
+
"--log-level",
|
|
59
|
+
"warning",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
process = subprocess.Popen(
|
|
63
|
+
start_cmd, stdout=output, stderr=output, start_new_session=True
|
|
64
|
+
)
|
|
65
|
+
output.close()
|
|
66
|
+
|
|
67
|
+
# Check if server is actually up
|
|
68
|
+
health_url = f"http://{host}:{port}/api/health"
|
|
69
|
+
server_started = False
|
|
70
|
+
timeout = 60.0
|
|
71
|
+
start_time = time.time()
|
|
72
|
+
|
|
73
|
+
while time.time() - start_time < timeout:
|
|
74
|
+
exit_code = process.poll()
|
|
75
|
+
if exit_code is not None:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
res = requests.get(health_url, timeout=0.2)
|
|
80
|
+
if res.status_code == 200:
|
|
81
|
+
server_started = True
|
|
82
|
+
break
|
|
83
|
+
except requests.RequestException:
|
|
84
|
+
time.sleep(0.1)
|
|
85
|
+
|
|
86
|
+
if server_started:
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Server started in background on http://{host}:{port} (PID: {process.pid})"
|
|
89
|
+
)
|
|
90
|
+
logger.info(f"Logs available at: {LOG_FILE}")
|
|
91
|
+
else:
|
|
92
|
+
# if server didnt respond check if the subprocess is still up
|
|
93
|
+
|
|
94
|
+
# if not timeout and kill the process
|
|
95
|
+
if process.poll() is None:
|
|
96
|
+
process.terminate()
|
|
97
|
+
process.wait(timeout=1.0)
|
|
98
|
+
logger.error("Server start failed! Clens server timed out.")
|
|
99
|
+
|
|
100
|
+
logger.error("Failed to start clens server!")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_stop_server():
|
|
104
|
+
if not RUNTIME_FILE.exists():
|
|
105
|
+
logger.error("No running clens server found.")
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
os.kill(runtime_state.pid, signal.SIGTERM)
|
|
109
|
+
logger.info(f"Clens server stopped successfully (PID: {runtime_state.pid})")
|
|
110
|
+
except ProcessLookupError:
|
|
111
|
+
logger.info(f"Clens server was already stopped")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_server_status():
|
|
115
|
+
if RUNTIME_FILE.exists() and runtime_state.pid is not None:
|
|
116
|
+
try:
|
|
117
|
+
os.kill(runtime_state.pid, 0)
|
|
118
|
+
print(
|
|
119
|
+
f"Clens server is running on http://{runtime_state.active_host}:{runtime_state.active_port} (PID: {runtime_state.pid}) "
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
except ProcessLookupError, OSError:
|
|
123
|
+
pass
|
|
124
|
+
else:
|
|
125
|
+
print("Clens server isnt running!! Start the server with: clens server start")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_index_images(path: str) -> None:
|
|
129
|
+
root_dir = Path(path).absolute()
|
|
130
|
+
url = f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/index"
|
|
131
|
+
|
|
132
|
+
logger.info(f"Indexing the directory: {str(root_dir)}...")
|
|
133
|
+
logger.info(f"This may take a few moments...")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
response = requests.post(
|
|
137
|
+
url,
|
|
138
|
+
json={"path": str(root_dir)},
|
|
139
|
+
)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
|
|
142
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
143
|
+
|
|
144
|
+
logger.info(f"{data.message}")
|
|
145
|
+
|
|
146
|
+
except ValidationError as VE:
|
|
147
|
+
logger.error(f"API contract violation (Invalid response schema): {VE}")
|
|
148
|
+
except requests.exceptions.ConnectionError:
|
|
149
|
+
logger.error(f"Could not connect to {url}")
|
|
150
|
+
except requests.exceptions.Timeout:
|
|
151
|
+
logger.error(f"Request to {url} timed out")
|
|
152
|
+
except requests.exceptions.HTTPError as e:
|
|
153
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
154
|
+
logger.error(f"{data.message}")
|
|
155
|
+
except requests.exceptions.RequestException as e:
|
|
156
|
+
logger.error(f"Request failed: {e}")
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_find_images(query: str, count: int, plain: bool) -> None:
|
|
162
|
+
|
|
163
|
+
if count <= 0:
|
|
164
|
+
logger.error(f"count must be greater than 0: got -c/--count: {count}")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
url = urljoin(
|
|
168
|
+
f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/find/",
|
|
169
|
+
quote(query),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
logger.info(f"Finding results for {query}...")
|
|
173
|
+
logger.info(f"This may take a few moments...")
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
response = requests.get(
|
|
177
|
+
url,
|
|
178
|
+
params={"count": count},
|
|
179
|
+
)
|
|
180
|
+
response.raise_for_status()
|
|
181
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
182
|
+
|
|
183
|
+
logger.info(f"{data.message}")
|
|
184
|
+
if not plain:
|
|
185
|
+
display_results_table(data.body)
|
|
186
|
+
return
|
|
187
|
+
display_results_plain(data.body)
|
|
188
|
+
|
|
189
|
+
except ValidationError as VE:
|
|
190
|
+
logger.error(f"API contract violation (Invalid response schema): {VE}")
|
|
191
|
+
except requests.exceptions.ConnectionError:
|
|
192
|
+
logger.error(f"Connection: Could not connect to {url}")
|
|
193
|
+
except requests.exceptions.Timeout:
|
|
194
|
+
logger.error(f"Timeout: Request to {url} timed out")
|
|
195
|
+
except requests.exceptions.HTTPError:
|
|
196
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
197
|
+
logger.error(f"{data.message}")
|
|
198
|
+
if data.error_type == "ChromaNotFoundError":
|
|
199
|
+
logger.info(f"Index a directory first! Try: clens index <path>")
|
|
200
|
+
except requests.exceptions.RequestException as e:
|
|
201
|
+
logger.error(f"Request failed: {e}")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_match_image(img_path: str, count: int, plain: bool) -> None:
|
|
206
|
+
image_to_match = Path(img_path).absolute()
|
|
207
|
+
print(count)
|
|
208
|
+
|
|
209
|
+
if count <= 0:
|
|
210
|
+
logger.error(f"count must be greater than 0: got -c/--count: {count}")
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
url = f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/match"
|
|
214
|
+
|
|
215
|
+
logger.info(f"Finding matches for {os.path.basename(img_path)}...")
|
|
216
|
+
logger.info(f"This may take a few moments...")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
response = requests.get(
|
|
220
|
+
url,
|
|
221
|
+
params={"image_path": img_path, "count": count},
|
|
222
|
+
)
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
225
|
+
|
|
226
|
+
logger.info(f"{data.message}")
|
|
227
|
+
if not plain:
|
|
228
|
+
display_results_table(data.body)
|
|
229
|
+
return
|
|
230
|
+
display_results_plain(data.body)
|
|
231
|
+
|
|
232
|
+
except ValidationError as VE:
|
|
233
|
+
logger.error(f"API contract violation (Invalid response schema): {VE}")
|
|
234
|
+
except requests.exceptions.ConnectionError:
|
|
235
|
+
logger.error(f"Connection: Could not connect to {url}")
|
|
236
|
+
except requests.exceptions.Timeout:
|
|
237
|
+
logger.error(f"Timeout: Request to {url} timed out")
|
|
238
|
+
except requests.exceptions.HTTPError:
|
|
239
|
+
data = ResponseBody.model_validate_json(response.text)
|
|
240
|
+
if data.error_type == "ChromaNotFoundError":
|
|
241
|
+
logger.info(f"Index a directory first! Try: clens index <path>")
|
|
242
|
+
except requests.exceptions.RequestException as e:
|
|
243
|
+
logger.error(f"Request failed: {e}")
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def display_results_table(response) -> None:
|
|
248
|
+
results = response.get("message", response)
|
|
249
|
+
metadatas = results.get("metadatas", [[]])
|
|
250
|
+
distances = results.get("distances", [[]])
|
|
251
|
+
|
|
252
|
+
if not metadatas or not metadatas[0]:
|
|
253
|
+
print("\n\033[33mNo matching images found.\033[0m\n")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
items_meta = metadatas[0]
|
|
257
|
+
items_dist = distances[0] if distances else [0.0] * len(items_meta)
|
|
258
|
+
|
|
259
|
+
headers = ["RANK", "SCORE", "FILENAME", "PATH"]
|
|
260
|
+
rows = []
|
|
261
|
+
|
|
262
|
+
for i, (meta, dist) in enumerate(zip(items_meta, items_dist), start=1):
|
|
263
|
+
path_str = meta.get("path", "")
|
|
264
|
+
filename = Path(path_str).name or "Unknown"
|
|
265
|
+
|
|
266
|
+
# In ChromaDB cosine space: distance = 1 - cosine_similarity
|
|
267
|
+
# So: similarity = (1 - distance) * 100%
|
|
268
|
+
similarity = max(0.0, min(1.0, 1.0 - dist)) * 100
|
|
269
|
+
|
|
270
|
+
rows.append([f"#{i}", f"{similarity:.1f}%", filename, path_str])
|
|
271
|
+
|
|
272
|
+
# 1. Compute dynamic column widths so nothing wraps awkwardly
|
|
273
|
+
widths = [len(h) for h in headers]
|
|
274
|
+
for row in rows:
|
|
275
|
+
for idx, col in enumerate(row):
|
|
276
|
+
widths[idx] = max(widths[idx], len(col))
|
|
277
|
+
|
|
278
|
+
# 2. Build dividers and format strings
|
|
279
|
+
divider = "─" * (sum(widths) + 2 * (len(widths) - 1))
|
|
280
|
+
|
|
281
|
+
# 3. Print table with clean ANSI styling (hardcoded bright white base)
|
|
282
|
+
print()
|
|
283
|
+
header_str = " ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers))
|
|
284
|
+
print(f"\033[1;97m{header_str}\033[0m")
|
|
285
|
+
print(f"\033[97m{divider}\033[0m")
|
|
286
|
+
|
|
287
|
+
for rank, score, fname, pth in rows:
|
|
288
|
+
# Green score accent, clickable OSC 8 hyperlink on path, white base colors
|
|
289
|
+
# get file path with file:// prefixed
|
|
290
|
+
uri = Path(pth).resolve().as_uri()
|
|
291
|
+
# turn that into a hyprlink with OSC 8 esc sequence
|
|
292
|
+
linked_pth = f"\033]8;;{uri}\033\\{pth}\033]8;;\033\\"
|
|
293
|
+
print(
|
|
294
|
+
f"\033[97m{rank:<{widths[0]}}\033[0m "
|
|
295
|
+
f"\033[1;32m{score:<{widths[1]}}\033[0m "
|
|
296
|
+
f"\033[1;97m{fname:<{widths[2]}}\033[0m "
|
|
297
|
+
f"\033[97m{linked_pth}\033[0m"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
print(f"\033[97m{divider}\033[0m")
|
|
301
|
+
print(f"\033[97mFound {len(rows)} matching results\033[0m\n")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def display_results_plain(response) -> None:
|
|
305
|
+
results = response.get("message", response)
|
|
306
|
+
metadatas = results.get("metadatas", [[]])
|
|
307
|
+
distances = results.get("distances", [[]])
|
|
308
|
+
|
|
309
|
+
if not metadatas or not metadatas[0]:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
items_meta = metadatas[0]
|
|
313
|
+
items_dist = distances[0] if distances else [0.0] * len(items_meta)
|
|
314
|
+
|
|
315
|
+
headers = ["RANK", "SCORE", "FILENAME", "PATH"]
|
|
316
|
+
print("\t".join(headers))
|
|
317
|
+
|
|
318
|
+
for i, (meta, dist) in enumerate(zip(items_meta, items_dist), start=1):
|
|
319
|
+
path_str = meta.get("path", "")
|
|
320
|
+
filename = Path(path_str).name or "Unknown"
|
|
321
|
+
similarity = max(0.0, min(1.0, 1.0 - dist)) * 100
|
|
322
|
+
print(f"{i}\t{similarity:.1f}%\t{filename}\t{path_str}")
|
clens/data_schemas.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Generic, TypeVar, TypedDict
|
|
4
|
+
from numpy.typing import NDArray
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IndexDirData(BaseModel):
|
|
11
|
+
path: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResponseBody(BaseModel, Generic[T]):
|
|
15
|
+
message: str
|
|
16
|
+
body: T | None = None
|
|
17
|
+
error_type: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ImageData(TypedDict):
|
|
21
|
+
id: str
|
|
22
|
+
file_type: str
|
|
23
|
+
path: Path
|
|
24
|
+
embedding: NDArray[np.float32]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Candidate(TypedDict):
|
|
28
|
+
id: str
|
|
29
|
+
path: Path
|
clens/data_store.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from clens.paths import STORE_DIR
|
|
3
|
+
from clens.data_schemas import Candidate
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from chromadb.api import ClientAPI
|
|
7
|
+
from chromadb.api.types import QueryResult
|
|
8
|
+
from clens.embedder import ImageData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_chroma_client = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_chroma_client() -> ClientAPI:
|
|
15
|
+
from chromadb import PersistentClient
|
|
16
|
+
|
|
17
|
+
global _chroma_client
|
|
18
|
+
if _chroma_client is None:
|
|
19
|
+
_chroma_client = PersistentClient(path=STORE_DIR)
|
|
20
|
+
return _chroma_client
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def filter_preexisting_images(
|
|
24
|
+
client: ClientAPI, candidates: list[Candidate]
|
|
25
|
+
) -> list[Candidate]:
|
|
26
|
+
image_collection = client.get_or_create_collection(
|
|
27
|
+
"image_data", configuration={"hnsw": {"space": "cosine"}}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
existing_ids: set[str] = set(
|
|
31
|
+
image_collection.get(ids=[entry["id"] for entry in candidates])["ids"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if len(existing_ids) == 0:
|
|
35
|
+
return candidates
|
|
36
|
+
|
|
37
|
+
return [entry for entry in candidates if entry["id"] not in existing_ids]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def store_image_data(client: ClientAPI, image_data: list[ImageData]) -> None:
|
|
41
|
+
image_collection = client.get_or_create_collection(
|
|
42
|
+
"image_data", configuration={"hnsw": {"space": "cosine"}}
|
|
43
|
+
)
|
|
44
|
+
image_collection.add(
|
|
45
|
+
ids=[str(data["id"]) for data in image_data],
|
|
46
|
+
embeddings=[data["embedding"] for data in image_data],
|
|
47
|
+
metadatas=[
|
|
48
|
+
{"file_type": str(data["file_type"]), "path": str(data["path"])}
|
|
49
|
+
for data in image_data
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def retrieve_images(embedding, client: ClientAPI, result_count: int) -> QueryResult:
|
|
55
|
+
image_collection = client.get_collection("image_data")
|
|
56
|
+
return image_collection.query(query_embeddings=embedding, n_results=result_count)
|
clens/dir_crawler.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
image_types = ["jpg", "png"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_images(root_dir: str | Path) -> list[Path]:
|
|
7
|
+
root = Path(root_dir)
|
|
8
|
+
if root.is_file():
|
|
9
|
+
raise NotADirectoryError(f"Path must be a directory and not a file: {root}")
|
|
10
|
+
if not root.exists():
|
|
11
|
+
raise FileNotFoundError(f"No such directory found: {root}")
|
|
12
|
+
|
|
13
|
+
file_paths: list[Path] = []
|
|
14
|
+
for image_type in image_types:
|
|
15
|
+
for file_path in root.rglob(f"*.{image_type}"):
|
|
16
|
+
file_paths.append(file_path.absolute())
|
|
17
|
+
|
|
18
|
+
return file_paths
|
clens/embedder.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from filetype import guess_mime
|
|
3
|
+
import uuid
|
|
4
|
+
import hashlib
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
import numpy as np
|
|
7
|
+
from clens.data_schemas import ImageData, Candidate
|
|
8
|
+
|
|
9
|
+
CLIP_MODEL = "clip-ViT-B-32"
|
|
10
|
+
_model = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_model():
|
|
14
|
+
from sentence_transformers import SentenceTransformer
|
|
15
|
+
|
|
16
|
+
global _model
|
|
17
|
+
if _model is None:
|
|
18
|
+
try:
|
|
19
|
+
_model = SentenceTransformer(CLIP_MODEL, local_files_only=True)
|
|
20
|
+
except Exception:
|
|
21
|
+
_model = SentenceTransformer(CLIP_MODEL)
|
|
22
|
+
return _model
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def embed_image(image_data) -> NDArray[np.float32]:
|
|
26
|
+
|
|
27
|
+
model = get_model()
|
|
28
|
+
return model.encode(image_data, normalize_embeddings=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_image_hash(image_path: Path) -> str:
|
|
32
|
+
sha256 = hashlib.sha256()
|
|
33
|
+
with open(image_path, "rb") as img:
|
|
34
|
+
while chunk := img.read(65536):
|
|
35
|
+
sha256.update(chunk)
|
|
36
|
+
return sha256.hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def embed_images(files: list[Path]) -> list[ImageData]:
|
|
40
|
+
from clens.data_store import filter_preexisting_images, get_chroma_client
|
|
41
|
+
|
|
42
|
+
candidates: list[Candidate] = []
|
|
43
|
+
for file in files:
|
|
44
|
+
image_hash = generate_image_hash(file)
|
|
45
|
+
candidates.append({"id": image_hash, "path": file})
|
|
46
|
+
|
|
47
|
+
chroma_client = get_chroma_client()
|
|
48
|
+
|
|
49
|
+
new_images = filter_preexisting_images(chroma_client, candidates)
|
|
50
|
+
|
|
51
|
+
if len(new_images) == 0:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
embedded: list[ImageData] = []
|
|
55
|
+
for candidate in new_images:
|
|
56
|
+
from PIL import Image
|
|
57
|
+
|
|
58
|
+
pil_image = Image.open(candidate["path"])
|
|
59
|
+
embedding = embed_image(pil_image)
|
|
60
|
+
embedded.append(
|
|
61
|
+
{
|
|
62
|
+
"id": candidate["id"],
|
|
63
|
+
"file_type": guess_mime(candidate["path"]),
|
|
64
|
+
"path": candidate["path"],
|
|
65
|
+
"embedding": embedding,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return embedded
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def embed_text(query_text: str) -> NDArray[np.float32]:
|
|
72
|
+
model = get_model()
|
|
73
|
+
|
|
74
|
+
return model.encode(query_text, normalize_embeddings=True)
|
clens/indexer.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from clens.dir_crawler import find_images
|
|
2
|
+
from clens.embedder import embed_images
|
|
3
|
+
from clens.data_store import get_chroma_client, store_image_data
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def index_images(root_dir: str) -> None:
|
|
7
|
+
# get images recursively form the provided dir
|
|
8
|
+
file_paths = find_images(root_dir)
|
|
9
|
+
|
|
10
|
+
# generate embeddings from images
|
|
11
|
+
image_data = embed_images(file_paths)
|
|
12
|
+
|
|
13
|
+
if len(image_data) == 0:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
# get the shared chroma client
|
|
17
|
+
chroma_client = get_chroma_client()
|
|
18
|
+
|
|
19
|
+
# store the embeddings in chroma db
|
|
20
|
+
store_image_data(chroma_client, image_data)
|
clens/paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
APP = "clens"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _xdg(env: str, fallback: Path) -> Path:
|
|
8
|
+
base = os.environ.get(env)
|
|
9
|
+
return Path(base) if base else fallback
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", Path.home() / ".config") / APP
|
|
13
|
+
DATA_DIR = _xdg("XDG_DATA_HOME", Path.home() / ".local/share") / APP
|
|
14
|
+
STATE_DIR = _xdg("XDG_STATE_HOME", Path.home() / ".local/state") / APP
|
|
15
|
+
|
|
16
|
+
_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
17
|
+
RUNTIME_DIR = (Path(_runtime) if _runtime else STATE_DIR) / APP
|
|
18
|
+
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
20
|
+
STORE_DIR = DATA_DIR / "chroma"
|
|
21
|
+
LOG_FILE = STATE_DIR / "logs" / "clens.log"
|
|
22
|
+
RUNTIME_FILE = RUNTIME_DIR / "runtime.json"
|
clens/runtime.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from clens.paths import RUNTIME_FILE
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RuntimeState(BaseModel):
|
|
9
|
+
active_host: str | None = None
|
|
10
|
+
active_port: int | None = None
|
|
11
|
+
pid: int | None = None
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def load_runtime(cls, path: Path = RUNTIME_FILE) -> "RuntimeState":
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return cls()
|
|
17
|
+
try:
|
|
18
|
+
with open(path, "r") as f:
|
|
19
|
+
runtime_data = json.load(f)
|
|
20
|
+
return cls.model_validate(runtime_data)
|
|
21
|
+
except:
|
|
22
|
+
return cls()
|
|
23
|
+
|
|
24
|
+
def save_runtime(self, path: Path = RUNTIME_FILE) -> None:
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
with open(path, "w") as f:
|
|
27
|
+
f.write(self.model_dump_json(indent=2))
|
|
28
|
+
|
|
29
|
+
def clear_runtime(self, path: Path = RUNTIME_FILE) -> None:
|
|
30
|
+
if path.exists():
|
|
31
|
+
os.remove(path)
|
clens/search.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def find_images(query_text: str, count: int):
|
|
6
|
+
from clens.embedder import embed_text
|
|
7
|
+
from clens.data_store import get_chroma_client, retrieve_images
|
|
8
|
+
|
|
9
|
+
query_text_embd = embed_text(query_text)
|
|
10
|
+
chroma_client = get_chroma_client()
|
|
11
|
+
|
|
12
|
+
return retrieve_images(query_text_embd, chroma_client, count)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def match_images(img_path: Path, count: int):
|
|
16
|
+
from clens.embedder import embed_image
|
|
17
|
+
from clens.data_store import get_chroma_client, retrieve_images
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
image_data = Image.open(img_path)
|
|
21
|
+
image_embedding = embed_image(image_data)
|
|
22
|
+
|
|
23
|
+
chroma_client = get_chroma_client()
|
|
24
|
+
|
|
25
|
+
return retrieve_images(image_embedding, chroma_client, count)
|
clens/server.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from fastapi import FastAPI, Response, status
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from clens.data_schemas import IndexDirData, ResponseBody
|
|
4
|
+
import logging
|
|
5
|
+
from clens.embedder import get_model
|
|
6
|
+
from clens.data_store import get_chroma_client
|
|
7
|
+
import asyncio
|
|
8
|
+
from clens.runtime import RuntimeState
|
|
9
|
+
from chromadb.errors import NotFoundError as ChromaNotFoundError
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
runtime_state = RuntimeState().load_runtime()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def lifespan(app: FastAPI):
|
|
17
|
+
runtime_state.active_host = os.environ.get("CLENS_RUNTIME_HOST")
|
|
18
|
+
runtime_state.active_port = int(os.environ.get("CLENS_RUNTIME_PORT"))
|
|
19
|
+
runtime_state.pid = os.getpid()
|
|
20
|
+
runtime_state.save_runtime()
|
|
21
|
+
await asyncio.to_thread(get_model)
|
|
22
|
+
get_chroma_client()
|
|
23
|
+
yield
|
|
24
|
+
runtime_state.clear_runtime()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
clens_server = FastAPI(lifespan=lifespan)
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO, format=" [%(levelname)s] %(name)s: %(message)s", force=True
|
|
31
|
+
)
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@clens_server.get("/api/health", response_model=ResponseBody)
|
|
36
|
+
def health():
|
|
37
|
+
return ResponseBody(message="Status OK")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@clens_server.post("/api/index", response_model=ResponseBody)
|
|
41
|
+
def index_dir(req: IndexDirData, response: Response):
|
|
42
|
+
from clens.indexer import index_images
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
index_images(req.path)
|
|
46
|
+
return ResponseBody(message=f"The directory {req.path} has been indexed")
|
|
47
|
+
except NotADirectoryError as NAD:
|
|
48
|
+
response.status_code = status.HTTP_400_BAD_REQUEST
|
|
49
|
+
return ResponseBody(message=f"{NAD}", error_type="NotADirectoryError")
|
|
50
|
+
except FileNotFoundError as FNF:
|
|
51
|
+
response.status_code = status.HTTP_400_BAD_REQUEST
|
|
52
|
+
return ResponseBody(message=f"{FNF}", error_type="FileNotFoundError")
|
|
53
|
+
except Exception as E:
|
|
54
|
+
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
55
|
+
return ResponseBody(
|
|
56
|
+
message=f"Something went wrong", error_type="InternalServerError"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@clens_server.get("/api/find/{query_term}", response_model=ResponseBody)
|
|
61
|
+
def find_image(query_term: str, response: Response, count: int = 5):
|
|
62
|
+
from clens.search import find_images
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
result = find_images(query_term, count)
|
|
66
|
+
return ResponseBody(message=f"Found results for {query_term}", body=result)
|
|
67
|
+
except ChromaNotFoundError:
|
|
68
|
+
response.status_code = status.HTTP_400_BAD_REQUEST
|
|
69
|
+
return ResponseBody(
|
|
70
|
+
message="Seems like you have no indexed directories",
|
|
71
|
+
error_type="ChromaNotFoundError",
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
75
|
+
return ResponseBody(
|
|
76
|
+
message=f"Something went wrong", error_type="InternalServerError"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@clens_server.get("/api/match", response_model=ResponseBody)
|
|
81
|
+
def match_image(image_path: str, response: Response, count: int = 5):
|
|
82
|
+
from clens.search import match_images
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = match_images(Path(image_path).absolute(), count)
|
|
87
|
+
return ResponseBody(
|
|
88
|
+
message=f"Found matches for {os.path.basename(image_path)}", body=result
|
|
89
|
+
)
|
|
90
|
+
except ChromaNotFoundError:
|
|
91
|
+
response.status_code = status.HTTP_400_BAD_REQUEST
|
|
92
|
+
return ResponseBody(
|
|
93
|
+
message="Seems like you have no indexed directories",
|
|
94
|
+
error_type="ChromaNotFoundError",
|
|
95
|
+
)
|
|
96
|
+
except Exception:
|
|
97
|
+
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
98
|
+
return ResponseBody(
|
|
99
|
+
message=f"Something went wrong", error_type="InternalServerError"
|
|
100
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: clens-cli
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Local semantic image search tool
|
|
5
|
+
Keywords: cli,image search,semantic search,computer vision
|
|
6
|
+
Author: k4rkie
|
|
7
|
+
Author-email: k4rkie <karkeekrish07@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 k4rkie
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
33
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
34
|
+
Requires-Dist: chromadb>=1.5.9
|
|
35
|
+
Requires-Dist: fastapi[standard]>=0.142.2
|
|
36
|
+
Requires-Dist: filetype>=1.2.0
|
|
37
|
+
Requires-Dist: pillow>=12.3.0
|
|
38
|
+
Requires-Dist: sentence-transformers>=6.1.0
|
|
39
|
+
Requires-Dist: torch>=2.14.0
|
|
40
|
+
Requires-Python: >=3.14
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
<h1 align="center">clens</h1>
|
|
44
|
+
|
|
45
|
+
Clens is a semantic image search tool that allows users
|
|
46
|
+
to index a given directory of images and search through it
|
|
47
|
+
using natural language, search for matching images using
|
|
48
|
+
another image etc. locally on their device.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
Clens currently requires Python 3.14 or newer. The recommended installation
|
|
53
|
+
uses the CPU-only PyTorch packages, so it does not download NVIDIA/CUDA
|
|
54
|
+
libraries on CPU machines.
|
|
55
|
+
|
|
56
|
+
### Install with uv
|
|
57
|
+
|
|
58
|
+
Install the published package as an isolated command-line tool. The extra
|
|
59
|
+
PyTorch index is needed because CPU-only PyTorch wheels are published there:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv tool install \
|
|
63
|
+
--index https://download.pytorch.org/whl/cpu \
|
|
64
|
+
--default-index https://pypi.org/simple \
|
|
65
|
+
clens-cli
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Install with pipx
|
|
69
|
+
|
|
70
|
+
`pipx` also installs command-line applications into an isolated environment:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pipx install \
|
|
74
|
+
--pip-args="--index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple" \
|
|
75
|
+
clens-cli
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
After installation, verify the command:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
clens version
|
|
82
|
+
clens --help
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The first indexing or search operation downloads the local CLIP model so may take a few minutes. It is
|
|
86
|
+
cached locally and reused for later runs.
|
|
87
|
+
|
|
88
|
+
## Usage
|
|
89
|
+
|
|
90
|
+
Clens uses a local background server (FastAPI + ChromaDB + CLIP) to keep embeddings loaded in memory for faster indexing and search.
|
|
91
|
+
|
|
92
|
+
### 1. Server
|
|
93
|
+
|
|
94
|
+
Before indexing or searching, make sure the clens server is running.
|
|
95
|
+
|
|
96
|
+
Start the server:
|
|
97
|
+
```bash
|
|
98
|
+
# Start in current shell
|
|
99
|
+
clens server start
|
|
100
|
+
|
|
101
|
+
# Or detach to run in background
|
|
102
|
+
clens server start --detach
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> Defaults to `127.0.0.1:6969`. You can pass custom host/port:
|
|
106
|
+
> ```bash
|
|
107
|
+
> clens server start --port 6969 --detach
|
|
108
|
+
> ```
|
|
109
|
+
|
|
110
|
+
Check status or stop:
|
|
111
|
+
```bash
|
|
112
|
+
# Check if server is running
|
|
113
|
+
clens server status
|
|
114
|
+
|
|
115
|
+
# Stop running server
|
|
116
|
+
clens server stop
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### 2. Index a Directory
|
|
122
|
+
|
|
123
|
+
Recursively indexes `.png` and `.jpg` images from a directory and stores embeddings in local ChromaDB:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
clens index ./Pictures
|
|
127
|
+
```
|
|
128
|
+
*Indexing may take a few moments depending on the number of images in the directory and your hardware.*
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### 3. Find Images with Text (`find`)
|
|
133
|
+
|
|
134
|
+
Search indexed images with natural language:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
clens find "a dog with a hat"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Change number of results with `-c` / `--count` (default is 5):
|
|
141
|
+
```bash
|
|
142
|
+
clens find "a dog with a hat" -c 10
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### 4. Find Matching Images (`match`)
|
|
148
|
+
|
|
149
|
+
Pass an image to find visually and semantically matching images in your indexed directories:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
clens match ./sample_images/dog.png
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Specify number of matches:
|
|
156
|
+
```bash
|
|
157
|
+
clens match ./sample_images/dog.png -c 3
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### 5. Plain Output for Scripts & Piping (`-p`)
|
|
163
|
+
|
|
164
|
+
By default, clens renders an ANSI table with match percentage and clickable file paths. Use `-p` / `--plain` to get raw tab-separated output (`RANK`, `SCORE`, `FILENAME`, `PATH`):
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Tab-separated output
|
|
168
|
+
clens find "sunset" -p
|
|
169
|
+
|
|
170
|
+
# Example: pipe top result to xdg-open
|
|
171
|
+
clens find "cat sleeping" -c 1 -p | awk 'NR==2 {print $4}' | xargs xdg-open
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
### Configuration
|
|
177
|
+
|
|
178
|
+
Clens automatically generates a config file at `~/.config/clens/config.json` (or `$XDG_CONFIG_HOME/clens/config.json`):
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"preferences": {
|
|
183
|
+
"default_host": "127.0.0.1",
|
|
184
|
+
"default_port": 6969
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Currently available options:
|
|
190
|
+
* `preferences.default_host`: Default host the server binds to and client commands connect to (default: `"127.0.0.1"`).
|
|
191
|
+
* `preferences.default_port`: Default port used by the server and CLI (default: `6969`).
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Commands
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
clens server start [-H HOST] [-p PORT] [-d] Start clens server (-d to detach)
|
|
199
|
+
clens server status Get server status
|
|
200
|
+
clens server stop Stop running server
|
|
201
|
+
clens index <path> Index a directory of images
|
|
202
|
+
clens find <query_term> [-c COUNT] [-p] Find images by text description
|
|
203
|
+
clens match <image_path> [-c COUNT] [-p] Find images matching an existing image
|
|
204
|
+
clens version Print current version
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
For indepth info about the program
|
|
208
|
+
```bash
|
|
209
|
+
clens -h/-help
|
|
210
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
clens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
clens/app_config.py,sha256=anvL6RbEIi0twmBSdi5hbXh7uzOHkEA_iqdBpESxR-c,847
|
|
3
|
+
clens/cli.py,sha256=9Q2g6HcOXMkqdT2uavxSUvBg7d62jSlx5mPaC2rY7Go,11623
|
|
4
|
+
clens/command_handlers.py,sha256=qR6OaA9erDmNO7N8QhKj5N7fRam4WPCEbEFxZ4mWElE,10804
|
|
5
|
+
clens/data_schemas.py,sha256=6_afD63bt8DWp3jdTeVntnsPjxeyamsKD97SQKtK7_4,510
|
|
6
|
+
clens/data_store.py,sha256=3_2RQwgbU3Qr7KMycv1oTNMu2CGKCv5j_1YcBtvozQE,1725
|
|
7
|
+
clens/dir_crawler.py,sha256=mo7PlZwnLUbO5buoCfey-7RJFLwGEnZadhVvtFcg-RM,537
|
|
8
|
+
clens/embedder.py,sha256=0TBTuIO_MD_MvducK3eYqWjDT-clXuMcSCWUfYgbndw,1972
|
|
9
|
+
clens/indexer.py,sha256=VHPYjooMnAUBh7CQ8x3oQwe9pNikO3ofeLCnlWb4kww,571
|
|
10
|
+
clens/paths.py,sha256=g1WCUBBkD6Q3FcUzvSA2SM3EpamHF8lnq1umx0mkzg0,653
|
|
11
|
+
clens/runtime.py,sha256=xf7u54O45wKygHvJpSDqpD2MY-I21brh-ey3dPvC8bc,911
|
|
12
|
+
clens/search.py,sha256=EzC5WmlQf6Zgb6tAXT7Bjg7QzJkXpcudsw5ZgG16730,736
|
|
13
|
+
clens/server.py,sha256=dxw0lFkt5b1ul-grYqNp-bpEruwT8h7llpLQ_3-OWRs,3588
|
|
14
|
+
clens_cli-1.0.1.dist-info/WHEEL,sha256=mru_b36sH6joUMnwf7IlFCun3RoDjrNg9RcfBmEcqsE,81
|
|
15
|
+
clens_cli-1.0.1.dist-info/entry_points.txt,sha256=06jhwVe7CveD15J4Sezfw6-HQHEGH-CNvD6uRUaXNwY,41
|
|
16
|
+
clens_cli-1.0.1.dist-info/METADATA,sha256=7DBy2qEwKhHAkdN6YbLDgAZFc_EIvWt2BThgeoPJXfQ,6098
|
|
17
|
+
clens_cli-1.0.1.dist-info/RECORD,,
|