pixelbudget 0.3.0__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.
@@ -0,0 +1,45 @@
1
+ """pixelbudget: offline image-token estimates, budget fit and readable slicing for vision LLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.3.0"
6
+
7
+ from .errors import (
8
+ ImageTooLargeError,
9
+ InvalidBudgetError,
10
+ InvalidOverlapError,
11
+ InvalidSizeError,
12
+ ModelTableError,
13
+ PixelBudgetError,
14
+ UnknownModelError,
15
+ UnreadableImageError,
16
+ UnsupportedDetailError,
17
+ )
18
+ from .estimate import estimate
19
+ from .fit import fit, fit_size
20
+ from .measure import measure
21
+ from .registry import get_model, list_models
22
+ from .report import build_report, report
23
+ from .slicing import plan_slices, slice_image
24
+ from .types import (
25
+ Estimate,
26
+ FitPlan,
27
+ FitResult,
28
+ ImageSource,
29
+ ModelSpec,
30
+ Piece,
31
+ ReportResult,
32
+ ReportRow,
33
+ Size,
34
+ SlicePlan,
35
+ SliceResult,
36
+ )
37
+
38
+ __all__ = [
39
+ "__version__", "estimate", "fit", "fit_size", "measure", "get_model", "list_models", "report",
40
+ "build_report", "plan_slices", "slice_image", "Estimate", "FitPlan", "FitResult", "ImageSource",
41
+ "ModelSpec", "Piece", "ReportResult", "ReportRow", "Size", "SlicePlan", "SliceResult",
42
+ "PixelBudgetError", "UnknownModelError", "UnsupportedDetailError", "InvalidBudgetError",
43
+ "InvalidOverlapError", "InvalidSizeError", "UnreadableImageError", "ImageTooLargeError",
44
+ "ModelTableError",
45
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
pixelbudget/cli.py ADDED
@@ -0,0 +1,376 @@
1
+ """Command line interface (argparse). All user-facing text comes from ``locales/*.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ import unicodedata
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from typing import Sequence
13
+
14
+ from PIL import Image
15
+
16
+ from . import __version__
17
+ from .errors import PixelBudgetError
18
+ from .fit import fit, fit_size
19
+ from .i18n import LANGS, resolve_lang, t
20
+ from .measure import measure
21
+ from .registry import get_model, list_models, set_models_file
22
+ from .report import build_report, size_rows
23
+ from .types import ReportResult
24
+ from .slicing import plan_slices, slice_image
25
+
26
+
27
+ def _width(text: str) -> int:
28
+ """Terminal display width: East Asian wide/fullwidth characters (Hangul, CJK) take two columns."""
29
+ return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in text)
30
+
31
+
32
+ def _pad(text: str, width: int) -> str:
33
+ return text + " " * (width - _width(text))
34
+
35
+
36
+ def _table(headers: list[str], rows: list[list[str]]) -> str:
37
+ widths = [max(_width(h), *(_width(r[i]) for r in rows)) if rows else _width(h) for i, h in enumerate(headers)]
38
+ line = lambda cells: " ".join(_pad(c, w) for c, w in zip(cells, widths)).rstrip() # noqa: E731
39
+ return "\n".join([line(headers), line(["-" * w for w in widths]), *(line(r) for r in rows)])
40
+
41
+
42
+ class _Parser(argparse.ArgumentParser):
43
+ """ArgumentParser whose usage prefix, section titles and usage errors follow ``--lang``."""
44
+
45
+ def __init__(self, *args, lang: str = "en", **kwargs):
46
+ self._lang = lang
47
+ super().__init__(*args, add_help=False, **kwargs)
48
+ self._positionals.title = t(lang, "argparse.positional")
49
+ self._optionals.title = t(lang, "argparse.options")
50
+ self.add_argument("-h", "--help", action="help", help=t(lang, "help.help"))
51
+
52
+ def _get_formatter(self, *args, **kwargs):
53
+ formatter = super()._get_formatter(*args, **kwargs)
54
+ original = formatter.add_usage
55
+ prefix = t(self._lang, "argparse.usage")
56
+
57
+ def add_usage(usage, actions, groups, prefix_arg=None): # noqa: ARG001
58
+ return original(usage, actions, groups, prefix)
59
+
60
+ formatter.add_usage = add_usage
61
+ return formatter
62
+
63
+ def error(self, message: str):
64
+ self.print_usage(sys.stderr)
65
+ self.exit(2, f"{self.prog}: {t(self._lang, 'argparse.error')}: {_translate_argparse_error(self._lang, message)}\n")
66
+
67
+
68
+ _ERROR_PATTERNS = (
69
+ (re.compile(r"the following arguments are required: (.*)$"), "argparse.err.required"),
70
+ (re.compile(r"argument (.+?): invalid choice: (.+?) \(choose from (.*)\)$"), "argparse.err.choice"),
71
+ (re.compile(r"argument (.+?): expected one argument$"), "argparse.err.one_arg"),
72
+ (re.compile(r"argument (.+?): invalid (\w+) value: (.*)$"), "argparse.err.value"),
73
+ (re.compile(r"unrecognized arguments: (.*)$"), "argparse.err.unrecognized"),
74
+ (re.compile(r"ambiguous option: (.+?) could match (.*)$"), "argparse.err.ambiguous"),
75
+ )
76
+
77
+
78
+ def _translate_argparse_error(lang: str, message: str) -> str:
79
+ if lang == "en":
80
+ return message
81
+ for pattern, key in _ERROR_PATTERNS:
82
+ m = pattern.search(message)
83
+ if m:
84
+ return t(lang, key, **{f"a{i}": g for i, g in enumerate(m.groups(), 1)})
85
+ return message
86
+
87
+
88
+ def _yn(lang: str, value: bool) -> str:
89
+ return t(lang, "yes" if value else "no")
90
+
91
+
92
+ def _size(s: tuple[int, int]) -> str:
93
+ return f"{s[0]}x{s[1]}"
94
+
95
+
96
+ def _print_warnings(lang: str, codes: Sequence[str], out) -> None:
97
+ uniq = list(dict.fromkeys(codes))
98
+ if uniq:
99
+ print(t(lang, "warnings.title"), file=out)
100
+ for code in uniq:
101
+ print(f" - [{code}] {t(lang, 'warn.' + code)}", file=out)
102
+
103
+
104
+ def _build_parser(lang: str) -> argparse.ArgumentParser:
105
+ p = _Parser(prog="pixelbudget", description=t(lang, "app.description"), lang=lang)
106
+ p.add_argument("--lang", choices=LANGS, help=t(lang, "help.lang"))
107
+ p.add_argument("--models-file", metavar="PATH", help=t(lang, "help.models_file"))
108
+ p.add_argument("--version", action="version", version=f"pixelbudget {__version__}", help=t(lang, "help.version"))
109
+ sub = p.add_subparsers(dest="command", required=True, metavar="{models,estimate,fit,slice,report,demo}",
110
+ parser_class=partial(_Parser, lang=lang))
111
+
112
+ def add(name: str) -> argparse.ArgumentParser:
113
+ return sub.add_parser(name, help=t(lang, f"help.{name}"), description=t(lang, f"help.{name}"))
114
+
115
+ m = add("models")
116
+ m.add_argument("--format", choices=("table", "json"), default="table", help=t(lang, "help.format"))
117
+
118
+ def multi(sp: argparse.ArgumentParser) -> None:
119
+ sp.add_argument("paths", nargs="+", metavar="PATH", help=t(lang, "help.paths_size" if sp.prog.endswith("estimate") else "help.paths"))
120
+ sp.add_argument("--model", action="append", help=t(lang, "help.model"))
121
+ sp.add_argument("--detail", help=t(lang, "help.detail"))
122
+ sp.add_argument("--format", choices=("table", "json"), default="table", help=t(lang, "help.format"))
123
+ sp.add_argument("--strict", action="store_true", help=t(lang, "help.strict"))
124
+
125
+ e = add("estimate")
126
+ multi(e)
127
+ r = add("report")
128
+ multi(r)
129
+ r.add_argument("--max-tokens", type=int, help=t(lang, "help.max_tokens"))
130
+ r.add_argument("--price", action="append", metavar="MODEL=USD", help=t(lang, "help.price"))
131
+
132
+ f = add("fit")
133
+ f.add_argument("path", metavar="PATH", help=t(lang, "help.path"))
134
+ f.add_argument("--model", required=True, help=t(lang, "help.model_one"))
135
+ f.add_argument("--budget", type=int, help=t(lang, "help.budget"))
136
+ f.add_argument("--detail", help=t(lang, "help.detail"))
137
+ f.add_argument("--out", help=t(lang, "help.out"))
138
+ f.add_argument("--max-pixels", type=int, help=t(lang, "help.max_pixels"))
139
+
140
+ s = add("slice")
141
+ s.add_argument("path", metavar="PATH", help=t(lang, "help.path"))
142
+ s.add_argument("--model", required=True, help=t(lang, "help.model_one"))
143
+ s.add_argument("--overlap", type=int, default=64, help=t(lang, "help.overlap"))
144
+ s.add_argument("--detail", help=t(lang, "help.detail"))
145
+ s.add_argument("--out-dir", help=t(lang, "help.out_dir"))
146
+ s.add_argument("--dry-run", action="store_true", help=t(lang, "help.dry_run"))
147
+ s.add_argument("--max-pixels", type=int, help=t(lang, "help.max_pixels"))
148
+
149
+ d = add("demo")
150
+ d.add_argument("--out", default="pixelbudget-demo", help=t(lang, "help.demo_out"))
151
+ d.add_argument("--model", default="claude-standard", help=t(lang, "help.model_one"))
152
+ return p
153
+
154
+
155
+ def _parse_prices(items: list[str] | None) -> dict[str, float]:
156
+ prices: dict[str, float] = {}
157
+ for item in items or []:
158
+ name, sep, value = item.partition("=")
159
+ if not sep:
160
+ raise PixelBudgetError(key="price_format", item=item)
161
+ try:
162
+ prices[name] = float(value)
163
+ except ValueError:
164
+ raise PixelBudgetError(key="price_number", item=item) from None
165
+ return prices
166
+
167
+
168
+ def _check_save_path(path: Path) -> None:
169
+ """Fail before decoding when Pillow cannot pick a format from the output extension."""
170
+ ext = path.suffix.lower()
171
+ if ext not in Image.registered_extensions():
172
+ raise PixelBudgetError(key="unknown_extension", path=path.as_posix(), ext=path.suffix)
173
+
174
+
175
+ # OSError 종류별 메시지 키. 영어 템플릿은 파이썬 기본 형식("[Errno 2] ...")과 같아 영어 출력은 그대로다.
176
+ _OS_ERROR_KEYS = (
177
+ (FileNotFoundError, "os.not_found"),
178
+ (IsADirectoryError, "os.is_directory"),
179
+ (NotADirectoryError, "os.not_directory"),
180
+ (FileExistsError, "os.exists"),
181
+ (PermissionError, "os.permission"),
182
+ )
183
+
184
+
185
+ def _error_text(lang: str, exc: Exception) -> str:
186
+ """User-facing text of an error in ``lang`` (library errors and file system errors)."""
187
+ if isinstance(exc, PixelBudgetError):
188
+ return exc.render(lang) # 코드+인자로 언어별 템플릿을 채운다
189
+ if isinstance(exc, OSError) and exc.errno is not None and exc.filename is not None:
190
+ key = next((k for cls, k in _OS_ERROR_KEYS if isinstance(exc, cls)), "os.other")
191
+ return t(lang, key, errno=exc.errno, reason=exc.strerror, path=exc.filename)
192
+ return str(exc) # 형식을 알 수 없는 오류는 원문 그대로(영어일 수 있음)
193
+
194
+
195
+ def _cmd_models(args, lang, out) -> int:
196
+ specs = list_models()
197
+ if args.format == "json":
198
+ json.dump([s.as_dict() for s in specs], out, indent=2, ensure_ascii=False)
199
+ print(file=out)
200
+ return 0
201
+ headers = [t(lang, k) for k in ("col.model", "col.provider", "col.method", "col.confidence", "col.checked", "col.source", "col.origin")]
202
+ rows = [[s.name, s.provider, s.method, s.confidence, s.checked, s.source_url, s.origin] for s in specs]
203
+ print(_table(headers, rows), file=out)
204
+ return 0
205
+
206
+
207
+ # estimate 전용: 같은 이름의 파일·폴더가 없고 WxH 모양이면 이미지 없이 크기로 계산한다(파일 우선).
208
+ _SIZE_RE = re.compile(r"^\d+[xX]\d+$")
209
+
210
+
211
+ def _is_size_arg(arg: str) -> bool:
212
+ return bool(_SIZE_RE.match(arg)) and not Path(arg).exists()
213
+
214
+
215
+ def _estimate_mixed(args, models: list[str]) -> ReportResult:
216
+ """Handle estimate inputs one by one, in order: real paths via build_report, WxH via size_rows."""
217
+ rows: list = []
218
+ skipped: list[str] = []
219
+ for arg in args.paths:
220
+ if _is_size_arg(arg):
221
+ w, h = (int(n) for n in arg.lower().split("x"))
222
+ rows.extend(size_rows(arg, (w, h), models, detail=args.detail))
223
+ else:
224
+ part = build_report([arg], models, detail=args.detail)
225
+ rows.extend(part.rows)
226
+ skipped.extend(part.skipped)
227
+ return ReportResult(rows=tuple(rows), skipped=tuple(skipped))
228
+
229
+
230
+ def _cmd_report(args, lang, out, with_budget: bool) -> int:
231
+ models = args.model or ["claude-standard"]
232
+ max_tokens = getattr(args, "max_tokens", None) if with_budget else None
233
+ prices = _parse_prices(getattr(args, "price", None)) if with_budget else {}
234
+ if with_budget or not any(_is_size_arg(a) for a in args.paths):
235
+ result = build_report(args.paths, models, detail=args.detail, max_tokens=max_tokens, prices=prices)
236
+ else:
237
+ result = _estimate_mixed(args, models)
238
+ over = sum(1 for r in result.rows if r.over_budget)
239
+ failed = bool(over or (args.strict and result.skipped)) # --strict: unreadable files fail the run too
240
+ if args.format == "json":
241
+ payload = {"rows": [r.as_dict() for r in result.rows], "skipped": list(result.skipped),
242
+ "summary": {"rows": len(result.rows), "over_budget": over, "skipped": len(result.skipped)}}
243
+ json.dump(payload, out, indent=2, ensure_ascii=False)
244
+ print(file=out)
245
+ return 1 if failed else 0
246
+ headers = [t(lang, k) for k in ("col.path", "col.model", "col.original", "col.sent", "col.tokens", "col.downscaled", "col.wasted")]
247
+ if with_budget and max_tokens is not None:
248
+ headers.append(t(lang, "col.over"))
249
+ if prices:
250
+ headers.append(t(lang, "col.cost"))
251
+ rows = []
252
+ for r in result.rows:
253
+ row = [r.path, r.model, _size(r.original_size), _size(r.sent_size), str(r.tokens),
254
+ _yn(lang, r.server_downscaled), str(r.wasted_pixels)]
255
+ if with_budget and max_tokens is not None:
256
+ row.append(_yn(lang, r.over_budget))
257
+ if prices:
258
+ row.append("-" if r.cost_usd is None else f"{r.cost_usd:.6f}")
259
+ rows.append(row)
260
+ if not rows:
261
+ print(t(lang, "report.skipped_only" if result.skipped else "report.no_images"), file=out)
262
+ else:
263
+ print(_table(headers, rows), file=out)
264
+ _print_warnings(lang, [c for r in result.rows for c in r.warnings], out)
265
+ if result.skipped:
266
+ print(t(lang, "report.skipped", paths=", ".join(result.skipped)), file=out)
267
+ print(t(lang, "report.summary_one" if len(result.rows) == 1 else "report.summary", rows=len(result.rows), over=over, skipped=len(result.skipped)), file=out)
268
+ return 1 if failed else 0
269
+
270
+
271
+ def _save(img: Image.Image, path: Path) -> None:
272
+ if path.suffix.lower() in (".jpg", ".jpeg") and img.mode not in ("RGB", "L"):
273
+ img = img.convert("RGB")
274
+ path.parent.mkdir(parents=True, exist_ok=True)
275
+ img.save(path)
276
+
277
+
278
+ def _cmd_fit(args, lang, out) -> int:
279
+ size = measure(args.path)
280
+ plan = fit_size(size, args.model, budget=args.budget, detail=args.detail)
281
+ spec = get_model(args.model)
282
+ if plan.reason == "fixed_per_image":
283
+ print(t(lang, "fit.fixed_per_image", model=spec.name, tokens=plan.tokens), file=out)
284
+ if plan.suggest:
285
+ print(t(lang, "fit.suggest", budget=args.budget, detail=plan.suggest[0], tokens=plan.suggest[1]), file=out)
286
+ elif args.budget is not None and plan.tokens > args.budget:
287
+ print(t(lang, "fit.suggest_none", budget=args.budget), file=out)
288
+ return 0
289
+ if not plan.changed:
290
+ print(t(lang, "fit.unchanged", w=size[0], h=size[1], tokens=plan.tokens), file=out)
291
+ return 0
292
+ src = Path(args.path)
293
+ dest = Path(args.out) if args.out else src.with_name(f"{src.stem}.fit{src.suffix}")
294
+ _check_save_path(dest) # 디코드 전에 저장 형식부터 확인(트레이스백 대신 알기 쉬운 오류)
295
+ result = fit(args.path, args.model, budget=args.budget, detail=args.detail, max_pixels=args.max_pixels)
296
+ _save(result.image, dest)
297
+ print(t(lang, "fit.written", path=dest.as_posix(), w=plan.size[0], h=plan.size[1],
298
+ tokens=plan.tokens, scale=plan.scale), file=out)
299
+ return 0
300
+
301
+
302
+ def _cmd_slice(args, lang, out) -> int:
303
+ plan = plan_slices(measure(args.path), args.model, overlap=args.overlap, detail=args.detail)
304
+ print(t(lang, "slice.summary_one" if len(plan.pieces) == 1 else "slice.summary", n=len(plan.pieces), length=plan.piece_length, axis=t(lang, "axis." + plan.axis),
305
+ overlap=plan.overlap, total=plan.total_tokens, whole=plan.whole_tokens), file=out)
306
+ headers = [t(lang, k) for k in ("col.piece", "col.box", "col.size", "col.tokens", "col.scale", "col.downscaled")]
307
+ rows = [[str(p.index + 1), ",".join(map(str, p.box)), _size(p.size), str(p.tokens), f"{p.scale:.3f}",
308
+ _yn(lang, p.server_downscaled)] for p in plan.pieces]
309
+ print(_table(headers, rows), file=out)
310
+ _print_warnings(lang, plan.warnings, out)
311
+ if len(plan.pieces) == 1:
312
+ print(t(lang, "slice.single"), file=out)
313
+ return 0
314
+ if args.dry_run:
315
+ return 0
316
+ src = Path(args.path)
317
+ folder = Path(args.out_dir) if args.out_dir else src.with_name(f"{src.stem}-slices")
318
+ _check_save_path(folder / f"{src.stem}-01{src.suffix}")
319
+ result = slice_image(args.path, args.model, overlap=args.overlap, detail=args.detail, max_pixels=args.max_pixels)
320
+ for i, img in enumerate(result.images, 1):
321
+ _save(img, folder / f"{src.stem}-{i:02d}{src.suffix}")
322
+ print(t(lang, "slice.written", n=len(result.images), folder=folder.as_posix()), file=out)
323
+ return 0
324
+
325
+
326
+ def _cmd_demo(args, lang, out) -> int:
327
+ from .demo import run_demo
328
+
329
+ size, fitted, sliced = run_demo(args.out, args.model)
330
+ p0 = sliced.plan.pieces[0]
331
+ print(t(lang, "demo.title"), file=out)
332
+ print(t(lang, "demo.model", model=get_model(args.model).name), file=out)
333
+ print(t(lang, "demo.image", w=size[0], h=size[1]), file=out)
334
+ print(t(lang, "demo.fit", w=fitted.plan.size[0], h=fitted.plan.size[1], tokens=fitted.plan.tokens,
335
+ scale=fitted.plan.scale), file=out)
336
+ print(t(lang, "demo.slice", n=len(sliced.plan.pieces), pw=p0.size[0], ph=p0.size[1],
337
+ total=sliced.plan.total_tokens), file=out)
338
+ print(t(lang, "demo.tradeoff"), file=out)
339
+ print(t(lang, "demo.files", folder=Path(args.out).as_posix()), file=out)
340
+ _print_warnings(lang, sliced.plan.warnings, out)
341
+ return 0
342
+
343
+
344
+ def main(argv: Sequence[str] | None = None) -> int:
345
+ """Entry point for ``pixelbudget`` and ``python -m pixelbudget``. Returns the exit code."""
346
+ argv = list(sys.argv[1:] if argv is None else argv)
347
+ pre = argparse.ArgumentParser(add_help=False)
348
+ pre.add_argument("--lang")
349
+ known, _ = pre.parse_known_args(argv)
350
+ lang = resolve_lang(known.lang)
351
+ parser = _build_parser(lang)
352
+ args = parser.parse_args(argv)
353
+ out = sys.stdout
354
+ set_models_file(args.models_file) # --models-file 은 환경변수보다 우선한다
355
+ try:
356
+ if args.command == "models":
357
+ return _cmd_models(args, lang, out)
358
+ if args.command == "estimate":
359
+ return _cmd_report(args, lang, out, with_budget=False)
360
+ if args.command == "report":
361
+ return _cmd_report(args, lang, out, with_budget=True)
362
+ if args.command == "fit":
363
+ return _cmd_fit(args, lang, out)
364
+ if args.command == "slice":
365
+ return _cmd_slice(args, lang, out)
366
+ return _cmd_demo(args, lang, out)
367
+ except (PixelBudgetError, OSError) as exc:
368
+ # 라이브러리 예외는 영어 문구를 유지하고, 화면에는 고른 언어로 다시 렌더링해 보여 준다.
369
+ print(f"{t(lang, 'error.prefix')}: {_error_text(lang, exc)}", file=sys.stderr)
370
+ return 2
371
+ finally:
372
+ set_models_file(None)
373
+
374
+
375
+ if __name__ == "__main__": # pragma: no cover
376
+ sys.exit(main())