framelift 0.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.
- framelift/__init__.py +15 -0
- framelift/_internal/__init__.py +1 -0
- framelift/_internal/extract.py +80 -0
- framelift/_internal/sources.py +93 -0
- framelift/_internal/synth.py +97 -0
- framelift/cli.py +87 -0
- framelift/observation.py +50 -0
- framelift/pipeline.py +128 -0
- framelift/profile.py +61 -0
- framelift/py.typed +0 -0
- framelift-0.0.1.dist-info/METADATA +134 -0
- framelift-0.0.1.dist-info/RECORD +15 -0
- framelift-0.0.1.dist-info/WHEEL +4 -0
- framelift-0.0.1.dist-info/entry_points.txt +3 -0
- framelift-0.0.1.dist-info/licenses/LICENSE +21 -0
framelift/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""framelift: extract structured data from screen recordings and screenshots with vision LLMs.
|
|
2
|
+
|
|
3
|
+
The public API is `Profile` and `Pipeline` (plus the `Observation` record they produce).
|
|
4
|
+
Everything under ``framelift._internal`` may change in any release.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version as _version
|
|
8
|
+
|
|
9
|
+
from framelift.observation import Observation
|
|
10
|
+
from framelift.pipeline import Pipeline, RunResult
|
|
11
|
+
from framelift.profile import Profile, Validator
|
|
12
|
+
|
|
13
|
+
__all__ = ["Observation", "Pipeline", "Profile", "RunResult", "Validator", "__version__"]
|
|
14
|
+
|
|
15
|
+
__version__ = _version("framelift")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal building blocks. Not part of the public API; may change in any release."""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Structured extraction from one image with Pydantic AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pydantic_ai import Agent, AgentRetries, BinaryContent
|
|
11
|
+
from pydantic_ai.messages import RetryPromptPart
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pydantic_ai.models import Model
|
|
15
|
+
|
|
16
|
+
from framelift._internal.sources import ImageSource
|
|
17
|
+
from framelift.profile import Profile, Validator
|
|
18
|
+
|
|
19
|
+
USER_PROMPT = "Extract the data shown in this screenshot."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Extraction[OutputT: BaseModel]:
|
|
24
|
+
output: OutputT
|
|
25
|
+
input_tokens: int
|
|
26
|
+
output_tokens: int
|
|
27
|
+
requests: int
|
|
28
|
+
retries: int
|
|
29
|
+
latency_ms: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _adapt[OutputT: BaseModel](validator: Validator[OutputT]):
|
|
33
|
+
def run(output: OutputT) -> OutputT:
|
|
34
|
+
result = validator(output)
|
|
35
|
+
return output if result is None else result
|
|
36
|
+
|
|
37
|
+
return run
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def model_label(model: Model | str) -> str:
|
|
41
|
+
if isinstance(model, str):
|
|
42
|
+
return model
|
|
43
|
+
return f"{model.system}:{model.model_name}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Extractor[OutputT: BaseModel]:
|
|
47
|
+
"""Reads one image into ``profile.output_type``, retrying when validation fails."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, profile: Profile[OutputT], model: Model | str, *, max_retries: int) -> None:
|
|
50
|
+
self._model = model
|
|
51
|
+
self._agent: Agent[None, OutputT] = Agent(
|
|
52
|
+
output_type=profile.output_type,
|
|
53
|
+
instructions=profile.instructions,
|
|
54
|
+
retries=AgentRetries(output=max_retries),
|
|
55
|
+
name=f"framelift:{profile.name}",
|
|
56
|
+
)
|
|
57
|
+
for validator in profile.validators:
|
|
58
|
+
self._agent.output_validator(_adapt(validator))
|
|
59
|
+
|
|
60
|
+
def extract(self, image: ImageSource) -> Extraction[OutputT]:
|
|
61
|
+
started = time.perf_counter()
|
|
62
|
+
result = self._agent.run_sync(
|
|
63
|
+
[USER_PROMPT, BinaryContent(data=image.data, media_type=image.media_type)],
|
|
64
|
+
model=self._model,
|
|
65
|
+
)
|
|
66
|
+
latency_ms = round((time.perf_counter() - started) * 1000)
|
|
67
|
+
retries = sum(
|
|
68
|
+
isinstance(part, RetryPromptPart)
|
|
69
|
+
for message in result.all_messages()
|
|
70
|
+
for part in getattr(message, "parts", ())
|
|
71
|
+
)
|
|
72
|
+
usage = result.usage
|
|
73
|
+
return Extraction(
|
|
74
|
+
output=result.output,
|
|
75
|
+
input_tokens=usage.input_tokens,
|
|
76
|
+
output_tokens=usage.output_tokens,
|
|
77
|
+
requests=usage.requests,
|
|
78
|
+
retries=retries,
|
|
79
|
+
latency_ms=latency_ms,
|
|
80
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Resolve user inputs into image files and load them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import imagehash
|
|
12
|
+
from PIL import Image, UnidentifiedImageError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
|
|
17
|
+
IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
|
18
|
+
_MEDIA_TYPES = {"PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp"}
|
|
19
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ImageSource:
|
|
24
|
+
path: Path
|
|
25
|
+
data: bytes
|
|
26
|
+
media_type: str
|
|
27
|
+
sha256: str
|
|
28
|
+
phash: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_image(path: Path) -> bool:
|
|
32
|
+
return path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _glob(pattern: str) -> list[Path]:
|
|
36
|
+
p = Path(pattern)
|
|
37
|
+
if p.is_absolute():
|
|
38
|
+
root = Path(p.anchor)
|
|
39
|
+
relative = str(p.relative_to(root))
|
|
40
|
+
else:
|
|
41
|
+
root, relative = Path(), pattern
|
|
42
|
+
return sorted(root.glob(relative))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def expand_inputs(inputs: Iterable[str | Path]) -> list[Path]:
|
|
46
|
+
"""Expand files, directories and glob patterns into a de-duplicated list of image paths.
|
|
47
|
+
|
|
48
|
+
Directories contribute their image files (non-recursive, sorted). Raises `FileNotFoundError`
|
|
49
|
+
when an input matches nothing, so typos are not silently ignored.
|
|
50
|
+
"""
|
|
51
|
+
seen: set[Path] = set()
|
|
52
|
+
result: list[Path] = []
|
|
53
|
+
for item in inputs:
|
|
54
|
+
text = str(item)
|
|
55
|
+
if any(c in text for c in _GLOB_CHARS):
|
|
56
|
+
candidates = [p for p in _glob(text) if _is_image(p)]
|
|
57
|
+
else:
|
|
58
|
+
path = Path(text)
|
|
59
|
+
if path.is_dir():
|
|
60
|
+
candidates = sorted(p for p in path.iterdir() if _is_image(p))
|
|
61
|
+
elif path.is_file():
|
|
62
|
+
candidates = [path]
|
|
63
|
+
else:
|
|
64
|
+
candidates = []
|
|
65
|
+
if not candidates:
|
|
66
|
+
raise FileNotFoundError(f"No image matched input: {text}")
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
key = candidate.resolve()
|
|
69
|
+
if key not in seen:
|
|
70
|
+
seen.add(key)
|
|
71
|
+
result.append(candidate)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_image(path: Path) -> ImageSource:
|
|
76
|
+
"""Read an image, verify it decodes, and compute its SHA-256 and perceptual hash."""
|
|
77
|
+
data = path.read_bytes()
|
|
78
|
+
try:
|
|
79
|
+
with Image.open(io.BytesIO(data)) as image:
|
|
80
|
+
image.load()
|
|
81
|
+
media_type = _MEDIA_TYPES.get(image.format or "")
|
|
82
|
+
phash = str(imagehash.phash(image))
|
|
83
|
+
except UnidentifiedImageError as exc:
|
|
84
|
+
raise ValueError(f"Not a readable image: {path}") from exc
|
|
85
|
+
if media_type is None:
|
|
86
|
+
raise ValueError(f"Unsupported image format for {path}")
|
|
87
|
+
return ImageSource(
|
|
88
|
+
path=path,
|
|
89
|
+
data=data,
|
|
90
|
+
media_type=media_type,
|
|
91
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
92
|
+
phash=phash,
|
|
93
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Synthetic sample generator: screenshots of a fictional shop with known ground truth.
|
|
2
|
+
|
|
3
|
+
The shop, item names and prices are invented, so the images can be shared, used in tests and
|
|
4
|
+
used as a public benchmark without any real game or app content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Literal
|
|
13
|
+
|
|
14
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
Orientation = Literal["portrait", "landscape"]
|
|
20
|
+
|
|
21
|
+
_SHOP_PREFIXES = ["Moonlit", "Copper", "Wandering", "Silverleaf", "Lantern", "Hollow", "Amber"]
|
|
22
|
+
_SHOP_SUFFIXES = ["Emporium", "Bazaar", "Trading Post", "Market", "Workshop", "Pantry"]
|
|
23
|
+
_ITEM_ADJECTIVES = ["Moon", "Ember", "Frost", "Dew", "Iron", "Velvet", "Starlit", "Mossy", "Sun"]
|
|
24
|
+
_ITEM_NOUNS = ["Herb", "Ingot", "Thread", "Lantern", "Tonic", "Feather", "Pebble", "Bread", "Rope"]
|
|
25
|
+
|
|
26
|
+
_SIZES: dict[Orientation, tuple[int, int]] = {"portrait": (720, 1280), "landscape": (1280, 720)}
|
|
27
|
+
_HEADER_HEIGHT, _LIST_TOP, _ROW_HEIGHT = 110, 150, 84
|
|
28
|
+
_MIN_ITEMS, _MAX_ITEMS = 4, 7
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _max_rows(orientation: Orientation) -> int:
|
|
32
|
+
height = _SIZES[orientation][1]
|
|
33
|
+
return min(_MAX_ITEMS, (height - _LIST_TOP) // _ROW_HEIGHT)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SampleItem:
|
|
38
|
+
name: str
|
|
39
|
+
price: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SampleTruth:
|
|
44
|
+
file: str
|
|
45
|
+
orientation: Orientation
|
|
46
|
+
shop_name: str
|
|
47
|
+
items: list[SampleItem]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _random_truth(rng: random.Random, file: str, orientation: Orientation) -> SampleTruth:
|
|
51
|
+
shop = f"{rng.choice(_SHOP_PREFIXES)} {rng.choice(_SHOP_SUFFIXES)}"
|
|
52
|
+
names: set[str] = set()
|
|
53
|
+
count = rng.randint(_MIN_ITEMS, _max_rows(orientation))
|
|
54
|
+
while len(names) < count:
|
|
55
|
+
names.add(f"{rng.choice(_ITEM_ADJECTIVES)} {rng.choice(_ITEM_NOUNS)}")
|
|
56
|
+
items = [SampleItem(name=n, price=rng.randrange(10, 2000, 5)) for n in sorted(names)]
|
|
57
|
+
return SampleTruth(file=file, orientation=orientation, shop_name=shop, items=items)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render(truth: SampleTruth) -> Image.Image:
|
|
61
|
+
"""Draw a simple shop screen for ``truth``."""
|
|
62
|
+
width, height = _SIZES[truth.orientation]
|
|
63
|
+
image = Image.new("RGB", (width, height), (34, 30, 46))
|
|
64
|
+
draw = ImageDraw.Draw(image)
|
|
65
|
+
title = ImageFont.load_default(size=40)
|
|
66
|
+
body = ImageFont.load_default(size=30)
|
|
67
|
+
|
|
68
|
+
draw.rectangle((0, 0, width, _HEADER_HEIGHT), fill=(70, 52, 96))
|
|
69
|
+
draw.text((32, 34), truth.shop_name, font=title, fill=(250, 236, 200))
|
|
70
|
+
|
|
71
|
+
for index, item in enumerate(truth.items):
|
|
72
|
+
y = _LIST_TOP + index * _ROW_HEIGHT
|
|
73
|
+
fill = (52, 46, 70) if index % 2 == 0 else (44, 39, 60)
|
|
74
|
+
draw.rounded_rectangle((24, y, width - 24, y + _ROW_HEIGHT - 12), radius=12, fill=fill)
|
|
75
|
+
draw.text((48, y + 20), item.name, font=body, fill=(235, 235, 245))
|
|
76
|
+
price = f"{item.price} G"
|
|
77
|
+
price_width = draw.textlength(price, font=body)
|
|
78
|
+
draw.text((width - 48 - price_width, y + 20), price, font=body, fill=(255, 214, 102))
|
|
79
|
+
return image
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def generate(out_dir: Path, *, n: int, seed: int = 0) -> list[SampleTruth]:
|
|
83
|
+
"""Write ``n`` PNG screenshots and a ``truth.jsonl`` with their ground truth."""
|
|
84
|
+
if n < 1:
|
|
85
|
+
raise ValueError("n must be >= 1")
|
|
86
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
rng = random.Random(seed)
|
|
88
|
+
truths: list[SampleTruth] = []
|
|
89
|
+
for index in range(n):
|
|
90
|
+
orientation: Orientation = "portrait" if index % 2 == 0 else "landscape"
|
|
91
|
+
truth = _random_truth(rng, f"shop-{index:03d}.png", orientation)
|
|
92
|
+
render(truth).save(out_dir / truth.file, format="PNG")
|
|
93
|
+
truths.append(truth)
|
|
94
|
+
with (out_dir / "truth.jsonl").open("w", encoding="utf-8") as fh:
|
|
95
|
+
for truth in truths:
|
|
96
|
+
fh.write(json.dumps(asdict(truth), ensure_ascii=False) + "\n")
|
|
97
|
+
return truths
|
framelift/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command-line interface: ``framelift run`` and ``framelift synth``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from framelift._internal import synth as _synth
|
|
13
|
+
from framelift.pipeline import Pipeline
|
|
14
|
+
from framelift.profile import Profile
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Extract structured data from screen recordings and screenshots with vision LLMs.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_profile(spec: str) -> Profile:
|
|
24
|
+
"""Load a `Profile` from ``"package.module:attribute"``.
|
|
25
|
+
|
|
26
|
+
The current directory is put on ``sys.path`` so that local profile modules can be used.
|
|
27
|
+
"""
|
|
28
|
+
module_name, sep, attribute = spec.partition(":")
|
|
29
|
+
if not sep or not module_name or not attribute:
|
|
30
|
+
raise typer.BadParameter("expected 'package.module:attribute'", param_hint="--profile")
|
|
31
|
+
cwd = str(Path.cwd())
|
|
32
|
+
if cwd not in sys.path:
|
|
33
|
+
sys.path.insert(0, cwd)
|
|
34
|
+
try:
|
|
35
|
+
module = importlib.import_module(module_name)
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise typer.BadParameter(f"cannot import {module_name!r}: {exc}") from exc
|
|
38
|
+
profile = getattr(module, attribute, None)
|
|
39
|
+
if not isinstance(profile, Profile):
|
|
40
|
+
raise typer.BadParameter(f"{spec!r} is not a framelift Profile", param_hint="--profile")
|
|
41
|
+
return profile
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def run(
|
|
46
|
+
inputs: Annotated[list[str], typer.Argument(help="Image files, directories or glob patterns")],
|
|
47
|
+
profile: Annotated[str, typer.Option(help="Profile to use, as 'package.module:attribute'")],
|
|
48
|
+
model: Annotated[str, typer.Option(help="Pydantic AI model, e.g. 'anthropic:<model-name>'")],
|
|
49
|
+
out: Annotated[Path, typer.Option(help="Output directory")] = Path("out"),
|
|
50
|
+
max_retries: Annotated[int, typer.Option(min=0, help="Retries when validation fails")] = 2,
|
|
51
|
+
anonymize_paths: Annotated[
|
|
52
|
+
bool, typer.Option(help="Write only hashes, not input paths, to the output")
|
|
53
|
+
] = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Extract data from images and write observations.jsonl."""
|
|
56
|
+
pipeline = Pipeline(
|
|
57
|
+
load_profile(profile),
|
|
58
|
+
model=model,
|
|
59
|
+
out_dir=out,
|
|
60
|
+
max_retries=max_retries,
|
|
61
|
+
anonymize_paths=anonymize_paths,
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
result = pipeline.run(inputs)
|
|
65
|
+
except FileNotFoundError as exc:
|
|
66
|
+
raise typer.BadParameter(str(exc), param_hint="INPUTS") from exc
|
|
67
|
+
typer.echo(
|
|
68
|
+
f"{len(result.succeeded)} ok, {len(result.failed)} failed -> {result.output_path}",
|
|
69
|
+
err=True,
|
|
70
|
+
)
|
|
71
|
+
if result.failed:
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def synth(
|
|
77
|
+
out: Annotated[Path, typer.Option(help="Output directory")] = Path("samples"),
|
|
78
|
+
n: Annotated[int, typer.Option(min=1, help="Number of screenshots")] = 10,
|
|
79
|
+
seed: Annotated[int, typer.Option(help="Random seed")] = 0,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Generate screenshots of a fictional shop, with ground truth in truth.jsonl."""
|
|
82
|
+
truths = _synth.generate(out, n=n, seed=seed)
|
|
83
|
+
typer.echo(f"wrote {len(truths)} images and truth.jsonl to {out}", err=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
app()
|
framelift/observation.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The `framelift/observation/v1` record written to ``observations.jsonl``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
OBSERVATION_SCHEMA = "framelift/observation/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Record(BaseModel):
|
|
13
|
+
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProfileRef(_Record):
|
|
17
|
+
name: str
|
|
18
|
+
version: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SourceRef(_Record):
|
|
22
|
+
path: str | None = Field(description="Input path; omitted when paths are anonymized")
|
|
23
|
+
sha256: str
|
|
24
|
+
phash: str = Field(description="Perceptual hash (hex) of the image")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Usage(_Record):
|
|
28
|
+
input_tokens: int = 0
|
|
29
|
+
output_tokens: int = 0
|
|
30
|
+
requests: int = 0
|
|
31
|
+
latency_ms: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Observation(_Record):
|
|
35
|
+
"""What was read from one image."""
|
|
36
|
+
|
|
37
|
+
schema_id: Literal["framelift/observation/v1"] = Field(
|
|
38
|
+
default=OBSERVATION_SCHEMA, alias="schema"
|
|
39
|
+
)
|
|
40
|
+
status: Literal["ok", "error"]
|
|
41
|
+
profile: ProfileRef
|
|
42
|
+
source: SourceRef
|
|
43
|
+
data: dict[str, Any] | None = Field(description="The validated output; null on error")
|
|
44
|
+
error: str | None = None
|
|
45
|
+
model: str
|
|
46
|
+
usage: Usage
|
|
47
|
+
retries: int = Field(description="How many times validation asked the model to try again")
|
|
48
|
+
|
|
49
|
+
def to_json(self) -> str:
|
|
50
|
+
return self.model_dump_json(by_alias=True)
|
framelift/pipeline.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""`Pipeline`: run a `Profile` over images and write ``observations.jsonl``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
12
|
+
|
|
13
|
+
from framelift._internal.extract import Extractor, model_label
|
|
14
|
+
from framelift._internal.sources import expand_inputs, load_image
|
|
15
|
+
from framelift.observation import Observation, ProfileRef, SourceRef, Usage
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from pydantic_ai.models import Model
|
|
21
|
+
|
|
22
|
+
from framelift._internal.sources import ImageSource
|
|
23
|
+
from framelift.profile import Profile
|
|
24
|
+
|
|
25
|
+
OBSERVATIONS_FILE = "observations.jsonl"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RunResult:
|
|
30
|
+
observations: list[Observation] = field(default_factory=list)
|
|
31
|
+
output_path: Path | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def succeeded(self) -> list[Observation]:
|
|
35
|
+
return [o for o in self.observations if o.status == "ok"]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def failed(self) -> list[Observation]:
|
|
39
|
+
return [o for o in self.observations if o.status == "error"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Pipeline[OutputT: BaseModel]:
|
|
43
|
+
"""Extract ``profile.output_type`` from every input image.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
profile: What to read.
|
|
47
|
+
model: A Pydantic AI model or a ``"provider:model"`` string.
|
|
48
|
+
out_dir: Where to write ``observations.jsonl``. ``None`` keeps results in memory only.
|
|
49
|
+
max_retries: How many times a failed validation may ask the model to try again.
|
|
50
|
+
anonymize_paths: Omit input paths from observations, keeping only hashes.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
profile: Profile[OutputT],
|
|
56
|
+
*,
|
|
57
|
+
model: Model | str,
|
|
58
|
+
out_dir: str | Path | None = None,
|
|
59
|
+
max_retries: int = 2,
|
|
60
|
+
anonymize_paths: bool = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
if max_retries < 0:
|
|
63
|
+
raise ValueError("max_retries must be >= 0")
|
|
64
|
+
self.profile = profile
|
|
65
|
+
self.model = model
|
|
66
|
+
self.out_dir = Path(out_dir) if out_dir is not None else None
|
|
67
|
+
self.anonymize_paths = anonymize_paths
|
|
68
|
+
self._extractor = Extractor(profile, model, max_retries=max_retries)
|
|
69
|
+
|
|
70
|
+
def run(self, inputs: str | Path | Iterable[str | Path]) -> RunResult:
|
|
71
|
+
"""Process the inputs in order. A failure on one image does not stop the run."""
|
|
72
|
+
items = [inputs] if isinstance(inputs, str | Path) else list(inputs)
|
|
73
|
+
paths = expand_inputs(items)
|
|
74
|
+
|
|
75
|
+
result = RunResult()
|
|
76
|
+
writer = None
|
|
77
|
+
if self.out_dir is not None:
|
|
78
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
result.output_path = self.out_dir / OBSERVATIONS_FILE
|
|
80
|
+
writer = result.output_path.open("w", encoding="utf-8")
|
|
81
|
+
try:
|
|
82
|
+
for path in paths:
|
|
83
|
+
observation = self._observe(load_image(path))
|
|
84
|
+
result.observations.append(observation)
|
|
85
|
+
if writer is not None:
|
|
86
|
+
writer.write(observation.to_json() + "\n")
|
|
87
|
+
writer.flush()
|
|
88
|
+
finally:
|
|
89
|
+
if writer is not None:
|
|
90
|
+
writer.close()
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def _observe(self, image: ImageSource) -> Observation:
|
|
94
|
+
profile = ProfileRef(name=self.profile.name, version=self.profile.version)
|
|
95
|
+
source = SourceRef(
|
|
96
|
+
path=None if self.anonymize_paths else image.path.as_posix(),
|
|
97
|
+
sha256=image.sha256,
|
|
98
|
+
phash=image.phash,
|
|
99
|
+
)
|
|
100
|
+
model = model_label(self.model)
|
|
101
|
+
started = time.perf_counter()
|
|
102
|
+
try:
|
|
103
|
+
extraction = self._extractor.extract(image)
|
|
104
|
+
except UnexpectedModelBehavior as exc:
|
|
105
|
+
return Observation(
|
|
106
|
+
status="error",
|
|
107
|
+
profile=profile,
|
|
108
|
+
source=source,
|
|
109
|
+
data=None,
|
|
110
|
+
error=str(exc),
|
|
111
|
+
model=model,
|
|
112
|
+
usage=Usage(latency_ms=round((time.perf_counter() - started) * 1000)),
|
|
113
|
+
retries=0,
|
|
114
|
+
)
|
|
115
|
+
return Observation(
|
|
116
|
+
status="ok",
|
|
117
|
+
profile=profile,
|
|
118
|
+
source=source,
|
|
119
|
+
data=extraction.output.model_dump(mode="json"),
|
|
120
|
+
model=model,
|
|
121
|
+
usage=Usage(
|
|
122
|
+
input_tokens=extraction.input_tokens,
|
|
123
|
+
output_tokens=extraction.output_tokens,
|
|
124
|
+
requests=extraction.requests,
|
|
125
|
+
latency_ms=extraction.latency_ms,
|
|
126
|
+
),
|
|
127
|
+
retries=extraction.retries,
|
|
128
|
+
)
|
framelift/profile.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`Profile`: what to read from a screen and how to read it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
type Validator[OutputT: BaseModel] = Callable[[OutputT], OutputT | None]
|
|
13
|
+
"""A check run on every extracted output.
|
|
14
|
+
|
|
15
|
+
Raise `pydantic_ai.ModelRetry` with a message to make the model try again; return a (possibly
|
|
16
|
+
modified) output, or `None` to keep it as is.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, kw_only=True)
|
|
21
|
+
class Profile[OutputT: BaseModel]:
|
|
22
|
+
"""Defines what to extract from a screen.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
name: Identifier written to every observation, e.g. ``"fantasy-shop"``.
|
|
26
|
+
output_type: The Pydantic model to fill in. Field descriptions are passed to the model.
|
|
27
|
+
instructions: How to read the screen.
|
|
28
|
+
version: Bump this whenever the instructions or the output model change, so that
|
|
29
|
+
observations, caches and evaluation results can be told apart.
|
|
30
|
+
validators: Extra checks run on every output; see `Validator`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
output_type: type[OutputT]
|
|
35
|
+
instructions: str
|
|
36
|
+
version: str = "1"
|
|
37
|
+
validators: Sequence[Validator[OutputT]] = field(default_factory=tuple)
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not self.name.strip():
|
|
41
|
+
raise ValueError("Profile.name must not be empty")
|
|
42
|
+
if not self.version.strip():
|
|
43
|
+
raise ValueError("Profile.version must not be empty")
|
|
44
|
+
if not (isinstance(self.output_type, type) and issubclass(self.output_type, BaseModel)):
|
|
45
|
+
raise TypeError("Profile.output_type must be a pydantic BaseModel subclass")
|
|
46
|
+
object.__setattr__(self, "validators", tuple(self.validators))
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def fingerprint(self) -> str:
|
|
50
|
+
"""A stable hash of everything that affects extraction results."""
|
|
51
|
+
payload = json.dumps(
|
|
52
|
+
{
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"version": self.version,
|
|
55
|
+
"instructions": self.instructions,
|
|
56
|
+
"schema": self.output_type.model_json_schema(),
|
|
57
|
+
},
|
|
58
|
+
sort_keys=True,
|
|
59
|
+
ensure_ascii=False,
|
|
60
|
+
)
|
|
61
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
framelift/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: framelift
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Extract structured data from screen recordings and screenshots with vision LLMs.
|
|
5
|
+
Keywords: screenshot,screen-recording,vision,llm,structured-output,pydantic-ai
|
|
6
|
+
Author: rysk-tanaka
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: imagehash>=4.3
|
|
19
|
+
Requires-Dist: pillow>=12
|
|
20
|
+
Requires-Dist: pydantic>=2.14
|
|
21
|
+
Requires-Dist: pydantic-ai-slim>=2.57
|
|
22
|
+
Requires-Dist: typer>=0.27
|
|
23
|
+
Requires-Dist: pydantic-ai-slim[anthropic]>=2.57 ; extra == 'anthropic'
|
|
24
|
+
Requires-Dist: pydantic-evals>=2.57 ; extra == 'eval'
|
|
25
|
+
Requires-Dist: pydantic-ai-slim[google]>=2.57 ; extra == 'google'
|
|
26
|
+
Requires-Dist: pydantic-ai-slim[openai]>=2.57 ; extra == 'openai'
|
|
27
|
+
Requires-Dist: av>=19 ; extra == 'video'
|
|
28
|
+
Requires-Python: >=3.12
|
|
29
|
+
Project-URL: Repository, https://github.com/rysk-tanaka/framelift
|
|
30
|
+
Project-URL: Issues, https://github.com/rysk-tanaka/framelift/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/rysk-tanaka/framelift/blob/main/CHANGELOG.md
|
|
32
|
+
Provides-Extra: anthropic
|
|
33
|
+
Provides-Extra: eval
|
|
34
|
+
Provides-Extra: google
|
|
35
|
+
Provides-Extra: openai
|
|
36
|
+
Provides-Extra: video
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# framelift
|
|
40
|
+
|
|
41
|
+
Extract structured data from screen recordings and screenshots with vision LLMs.
|
|
42
|
+
|
|
43
|
+
You describe **what** to read as a Pydantic model and **how** to read it as a `Profile`;
|
|
44
|
+
framelift turns images into validated, provenance-tagged JSON Lines.
|
|
45
|
+
|
|
46
|
+
> **Status:** pre-alpha (0.x). The public API is limited to `Profile` and `Pipeline`
|
|
47
|
+
> and may change between minor versions.
|
|
48
|
+
|
|
49
|
+
## Features (0.1)
|
|
50
|
+
|
|
51
|
+
- Image input (files, directories, glob patterns), each tagged with its SHA-256 and perceptual hash
|
|
52
|
+
- Structured extraction with [Pydantic AI](https://ai.pydantic.dev/): your model is the output type,
|
|
53
|
+
your validators trigger automatic retries
|
|
54
|
+
- Model-agnostic: pass any `provider:model` string supported by Pydantic AI
|
|
55
|
+
- `observations.jsonl` output with source, usage and retry information per image
|
|
56
|
+
- A synthetic sample generator (a fictional shop screen) for demos and tests
|
|
57
|
+
|
|
58
|
+
Planned: video input with frame sampling and de-duplication (0.2), cross-frame aggregation (0.2),
|
|
59
|
+
screen classification, cropping and redaction (0.3), and an evaluation harness built on
|
|
60
|
+
`pydantic-evals`.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install "framelift[anthropic]" # or [openai], [google]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pydantic import BaseModel, Field
|
|
72
|
+
from framelift import Pipeline, Profile
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ShopItem(BaseModel):
|
|
76
|
+
name: str = Field(description="Item name as displayed")
|
|
77
|
+
price: int = Field(description="Price as an integer", gt=0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ShopScreen(BaseModel):
|
|
81
|
+
shop_name: str
|
|
82
|
+
items: list[ShopItem]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
shop = Profile(
|
|
86
|
+
name="fantasy-shop",
|
|
87
|
+
version="1",
|
|
88
|
+
output_type=ShopScreen,
|
|
89
|
+
instructions="Read the shop's item list exactly as displayed.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
Pipeline(shop, model="anthropic:<model-name>", out_dir="out").run(["shots/*.png"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or from the command line:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
framelift synth --out samples --n 3 # generate fictional shop screenshots
|
|
99
|
+
framelift run "samples/*.png" --profile examples.fantasy_shop:profile \
|
|
100
|
+
--model anthropic:<model-name> --out out
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Each line of `out/observations.jsonl` looks like:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{"schema": "framelift/observation/v1",
|
|
107
|
+
"profile": {"name": "fantasy-shop", "version": "1"},
|
|
108
|
+
"source": {"path": "samples/shop-000.png", "sha256": "…", "phash": "…"},
|
|
109
|
+
"data": {"shop_name": "…", "items": [{"name": "…", "price": 120}]},
|
|
110
|
+
"model": "anthropic:<model-name>",
|
|
111
|
+
"usage": {"input_tokens": 1534, "output_tokens": 96, "requests": 1, "latency_ms": 2104},
|
|
112
|
+
"retries": 0}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Privacy
|
|
116
|
+
|
|
117
|
+
framelift runs locally and only sends the images you pass to the model provider you choose.
|
|
118
|
+
It has no telemetry. Cropping and redaction before sending are planned for 0.3.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv sync --all-extras
|
|
124
|
+
uv run ruff check && uv run ruff format --check
|
|
125
|
+
uv run ty check
|
|
126
|
+
uv run pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Tests never call a real model (`ALLOW_MODEL_REQUESTS = False`); tests marked `live` are skipped by
|
|
130
|
+
default.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
framelift/__init__.py,sha256=eCsS2bK9kIMi4kcJRkj9jWKPIxj9fG6sox3PHK7fhKU,584
|
|
2
|
+
framelift/_internal/__init__.py,sha256=8Qw4tV8TxVRSwI2R_xrd5hg03a8qbuSGFS5giiYuVKY,87
|
|
3
|
+
framelift/_internal/extract.py,sha256=UyHIxUzXQpnTMUDRrm-b6E0HgDh4De3ObNAi28DRTTM,2515
|
|
4
|
+
framelift/_internal/sources.py,sha256=M2FcdPc9kYuSpkuk5le3KKRVPuKEU_-HdOQdL-eZ77o,2842
|
|
5
|
+
framelift/_internal/synth.py,sha256=WozAq3s7V0ioiypVtPuAbhT78qzC3dyn6EjMZ5f7-Us,3799
|
|
6
|
+
framelift/cli.py,sha256=gdCnMP_clefDwNPZ_04I5WGnfqQxzE7xh2FyCsZsSz0,3120
|
|
7
|
+
framelift/observation.py,sha256=4Fi6SFTIEBHf9vYzH2DIqiXg-qe-sGoYTZxtMW_2brY,1346
|
|
8
|
+
framelift/pipeline.py,sha256=DPHAe_WvS0teYT9BIAVC-svREwNCnxLRt9VSGm-hrb4,4523
|
|
9
|
+
framelift/profile.py,sha256=B6PKefUTxuNULHsF1Ig3x-8ZeNiC37M2r-_EjiZVwF8,2272
|
|
10
|
+
framelift/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
framelift-0.0.1.dist-info/licenses/LICENSE,sha256=bszw7czjvJg7ZuqKARjdB4LkUnRF_EuxwS9UXMF1rEQ,1068
|
|
12
|
+
framelift-0.0.1.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
|
|
13
|
+
framelift-0.0.1.dist-info/entry_points.txt,sha256=7l2QsN_czNwpCOi_WWhOYesWMC4GLHVH7ok3_EMStb8,49
|
|
14
|
+
framelift-0.0.1.dist-info/METADATA,sha256=-lkOpKv03n5bxFwgJ4eYuQteAQgQWh1EZXZ8BuAkZas,4460
|
|
15
|
+
framelift-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rysk-tanaka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|