osam 0.1.0__tar.gz

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,39 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ build:
11
+
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.8", "3.12"]
16
+
17
+ steps:
18
+ - uses: actions/setup-python@v4
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - uses: actions/checkout@v3
22
+
23
+ - name: Install main
24
+ run: |
25
+ pip install .[test]
26
+
27
+ - name: Lint
28
+ run: |
29
+ make lint
30
+
31
+ - name: Test
32
+ run: |
33
+ make test
34
+
35
+ - name: Install from dist
36
+ run: |
37
+ make build
38
+ pip install dist/osam-*.tar.gz
39
+ pip install dist/osam-*.whl
@@ -0,0 +1,117 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ build_binary:
10
+ runs-on: ${{ matrix.os }}
11
+
12
+ strategy:
13
+ matrix:
14
+ os: [macos-latest, ubuntu-20.04, windows-latest]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+
21
+ - name: Setup Python
22
+ uses: actions/setup-python@v4
23
+ with:
24
+ python-version: '3.10'
25
+ architecture: 'x64'
26
+ cache: 'pip'
27
+ cache-dependency-path: |
28
+ pyproject.toml
29
+
30
+ - name: Install main
31
+ run: |
32
+ pip install .
33
+
34
+ - name: Build Executable
35
+ uses: Nuitka/Nuitka-Action@main
36
+ with:
37
+ nuitka-version: main
38
+ script-name: osam
39
+ onefile: true
40
+
41
+ - name: Test Executable on Linux & macOS
42
+ run: |
43
+ ./build/osam.bin --version
44
+ if: matrix.os != 'windows-latest'
45
+
46
+ - name: Test Executable on Windows
47
+ run: |
48
+ ./build/osam.exe --version
49
+ if: matrix.os == 'windows-latest'
50
+
51
+ - name: Rename Executable
52
+ run: |
53
+ mv build/osam.bin build/osam-${{ runner.os }}.bin
54
+ if: matrix.os != 'windows-latest'
55
+
56
+ - name: Upload Artifacts
57
+ uses: actions/upload-artifact@v4
58
+ with:
59
+ name: build-${{ runner.os }}
60
+ path: |
61
+ build/*.exe
62
+ build/*.bin
63
+
64
+ release:
65
+ needs: build_binary
66
+
67
+ runs-on: ubuntu-latest
68
+
69
+ steps:
70
+ - name: Build Changelog
71
+ id: github_release
72
+ uses: mikepenz/release-changelog-builder-action@v3
73
+ env:
74
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75
+ with:
76
+ configurationJson: |
77
+ {
78
+ "template": "#{{CHANGELOG}}\n\n<details>\n<summary>Uncategorized</summary>\n\n#{{UNCATEGORIZED}}\n</details>\n\n---\n\n- For my daily development update, check [my Twitter/X](https://twitter.com/wkentaro_).\n- If you'd like to support this project, check [my sponsoring page](https://github.com/sponsors/wkentaro).",
79
+ "pr_template": "- #{{TITLE}} ##{{NUMBER}}",
80
+ "categories": [
81
+ {
82
+ "title": "## 🚀 Features",
83
+ "labels": ["feature"]
84
+ },
85
+ {
86
+ "title": "## ✨ Enhancement",
87
+ "labels": ["enhancement"]
88
+ },
89
+ {
90
+ "title": "## 🐛 Fixes",
91
+ "labels": ["fix"]
92
+ },
93
+ {
94
+ "title": "## 💬 Other",
95
+ "labels": ["other"]
96
+ }
97
+ ]
98
+ }
99
+
100
+ - name: Create Changelog
101
+ run: |
102
+ echo '${{steps.github_release.outputs.changelog}}' > ${{ github.workspace }}-CHANGELOG.txt
103
+
104
+ - name: Download Artifact
105
+ uses: actions/download-artifact@v4
106
+ with:
107
+ path: build
108
+ pattern: build-*
109
+ merge-multiple: true
110
+
111
+ - name: Release
112
+ uses: softprops/action-gh-release@v1
113
+ with:
114
+ draft: true
115
+ body_path: ${{ github.workspace }}-CHANGELOG.txt
116
+ files: |
117
+ build/*
Binary file
Binary file
Binary file
osam-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Kentaro Wada.
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.
osam-0.1.0/Makefile ADDED
@@ -0,0 +1,27 @@
1
+ all:
2
+ @echo '## Make commands ##'
3
+ @echo
4
+ @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs
5
+
6
+ lint:
7
+ mypy --package osam
8
+ ruff format --check
9
+ ruff check
10
+
11
+ format:
12
+ ruff format
13
+ ruff check --fix
14
+
15
+ test:
16
+ python -m pytest -n auto -v tests
17
+
18
+ clean:
19
+ rm -rf build dist *.egg-info
20
+
21
+ build: clean
22
+ python -m build --sdist --wheel
23
+
24
+ upload: build
25
+ python -m twine upload dist/osam-*
26
+
27
+ publish: build upload
osam-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.1
2
+ Name: osam
3
+ Version: 0.1.0
4
+ Summary: Get up and running vision foundational models locally.
5
+ Project-URL: Homepage, https://github.com/wkentaro/osam
6
+ Author-email: Kentaro Wada <www.kentaro.wada@gmail.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.8
23
+ Requires-Dist: click
24
+ Requires-Dist: fastapi
25
+ Requires-Dist: gdown>=5.1.0
26
+ Requires-Dist: loguru
27
+ Requires-Dist: onnxruntime
28
+ Requires-Dist: pillow
29
+ Requires-Dist: uvicorn
30
+ Provides-Extra: test
31
+ Requires-Dist: build; extra == 'test'
32
+ Requires-Dist: mypy; extra == 'test'
33
+ Requires-Dist: pytest; extra == 'test'
34
+ Requires-Dist: pytest-xdist; extra == 'test'
35
+ Requires-Dist: ruff; extra == 'test'
36
+ Requires-Dist: twine; extra == 'test'
37
+ Requires-Dist: types-pillow; extra == 'test'
38
+ Description-Content-Type: text/markdown
39
+
40
+ <div align="center">
41
+ <img alt="logo" height="200px" src=".readme/icon.png" >
42
+ <h1>Osam</h1>
43
+ <p>
44
+ Get up and running vision foundational models locally.
45
+ </p>
46
+ <br>
47
+ <br>
48
+ <br>
49
+ </div>
50
+
51
+ *Osam* (/oʊˈsɑm/) is a tool to run open source vision foundational models locally,
52
+ built inspired by [Ollama](https://github.com/ollama/ollama).
53
+
54
+ *Osam* gives you:
55
+
56
+ - **Visual foundational models** - Segment-Anything Model, Efficient-SAM, etc;
57
+ - **Local APIs** - CLI & Python & HTTP interface;
58
+ - **Customization** - Host custom vision models.
59
+
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install osam
65
+ ```
66
+
67
+
68
+ ## Usage
69
+
70
+ ### CLI
71
+
72
+ ```bash
73
+ # Run a model with an image
74
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg > output.jpg
75
+
76
+ # Get a JSON output
77
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg --json
78
+ # {"model": "efficient-sam:25m", "mask": "..."}
79
+
80
+ # Give a prompt
81
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg \
82
+ --prompt '{"points": [[1439, 504], [1439, 1289]], "point_labels": [1, 1]}' > output.jpg
83
+ ```
84
+
85
+ <img src="examples/_images/dogs.jpg" width="35%"> <img src=".readme/dogs_output.jpg" width="35%">
86
+ <i>Input and output images ('dogs.jpg', 'output.jpg').</i>
87
+
88
+ ### Python
89
+
90
+ ```python
91
+ import osam.apis
92
+ import osam.types
93
+
94
+ request = osam.types.GenerateRequest(
95
+ model="efficient-sam:25m",
96
+ image=np.asarray(PIL.Image.open("examples/_images/dogs.jpg")),
97
+ prompt=osam.types.Prompt(points=[[1439, 504], [1439, 1289]], point_labels=[1, 1]),
98
+ )
99
+ response = osam.apis.generate(request=request)
100
+ PIL.Image.fromarray(response.mask).save("mask.jpg")
101
+ ```
102
+ <img src="examples/_images/dogs.jpg" width="35%"> <img src=".readme/dogs_mask.jpg" width="35%">
103
+ <i>Input and output images ('dogs.jpg', 'mask.jpg').</i>
104
+
105
+ ### HTTP
106
+
107
+ ```bash
108
+ # Get up the server
109
+ osam serve
110
+
111
+ # POST request
112
+ curl 127.0.0.1:11368/api/generate -X POST \
113
+ -H "Content-Type: application/json" \
114
+ -d "{\"model\": \"efficient-sam:25m\", \"image\": \"$(cat examples/_images/dogs.jpg | base64)\"}" \
115
+ | jq -r .mask | base64 --decode > mask.jpg
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
osam-0.1.0/README.md ADDED
@@ -0,0 +1,81 @@
1
+ <div align="center">
2
+ <img alt="logo" height="200px" src=".readme/icon.png" >
3
+ <h1>Osam</h1>
4
+ <p>
5
+ Get up and running vision foundational models locally.
6
+ </p>
7
+ <br>
8
+ <br>
9
+ <br>
10
+ </div>
11
+
12
+ *Osam* (/oʊˈsɑm/) is a tool to run open source vision foundational models locally,
13
+ built inspired by [Ollama](https://github.com/ollama/ollama).
14
+
15
+ *Osam* gives you:
16
+
17
+ - **Visual foundational models** - Segment-Anything Model, Efficient-SAM, etc;
18
+ - **Local APIs** - CLI & Python & HTTP interface;
19
+ - **Customization** - Host custom vision models.
20
+
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install osam
26
+ ```
27
+
28
+
29
+ ## Usage
30
+
31
+ ### CLI
32
+
33
+ ```bash
34
+ # Run a model with an image
35
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg > output.jpg
36
+
37
+ # Get a JSON output
38
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg --json
39
+ # {"model": "efficient-sam:25m", "mask": "..."}
40
+
41
+ # Give a prompt
42
+ osam run efficient-sam:25m --image examples/_images/dogs.jpg \
43
+ --prompt '{"points": [[1439, 504], [1439, 1289]], "point_labels": [1, 1]}' > output.jpg
44
+ ```
45
+
46
+ <img src="examples/_images/dogs.jpg" width="35%"> <img src=".readme/dogs_output.jpg" width="35%">
47
+ <i>Input and output images ('dogs.jpg', 'output.jpg').</i>
48
+
49
+ ### Python
50
+
51
+ ```python
52
+ import osam.apis
53
+ import osam.types
54
+
55
+ request = osam.types.GenerateRequest(
56
+ model="efficient-sam:25m",
57
+ image=np.asarray(PIL.Image.open("examples/_images/dogs.jpg")),
58
+ prompt=osam.types.Prompt(points=[[1439, 504], [1439, 1289]], point_labels=[1, 1]),
59
+ )
60
+ response = osam.apis.generate(request=request)
61
+ PIL.Image.fromarray(response.mask).save("mask.jpg")
62
+ ```
63
+ <img src="examples/_images/dogs.jpg" width="35%"> <img src=".readme/dogs_mask.jpg" width="35%">
64
+ <i>Input and output images ('dogs.jpg', 'mask.jpg').</i>
65
+
66
+ ### HTTP
67
+
68
+ ```bash
69
+ # Get up the server
70
+ osam serve
71
+
72
+ # POST request
73
+ curl 127.0.0.1:11368/api/generate -X POST \
74
+ -H "Content-Type: application/json" \
75
+ -d "{\"model\": \"efficient-sam:25m\", \"image\": \"$(cat examples/_images/dogs.jpg | base64)\"}" \
76
+ | jq -r .mask | base64 --decode > mask.jpg
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT
Binary file
@@ -0,0 +1,7 @@
1
+ # Bash Shell Example
2
+
3
+ ## Usage
4
+
5
+ ```
6
+ ./comparemodels.sh
7
+ ```
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+
3
+ image_file='../_images/dogs.jpg'
4
+ number_of_choices=2
5
+
6
+ choices=$(osam list --all | awk '{print $1}' | sed '1d')
7
+
8
+ echo "Select $number_of_choices models to compare:"
9
+ select model in $choices; do
10
+ if [[ -n $model ]]; then
11
+ echo "You have selected $model"
12
+ selections+=("$model")
13
+ ((COUNT++))
14
+ if [[ $COUNT -eq $number_of_choices ]]; then
15
+ break
16
+ fi
17
+ else
18
+ echo "Invalid selection"
19
+ fi
20
+ done
21
+
22
+ output_files=()
23
+ for model in "${selections[@]}"; do
24
+ echo "Running $model"
25
+ output_file=${model/:/-}.jpg
26
+ osam run $model --image $image_file > $output_file
27
+ echo "Exported $model.jpg"
28
+ output_files+=($output_file)
29
+ done
30
+ echo "Compare results: ${output_files[@]}"
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version("osam")
@@ -0,0 +1,154 @@
1
+ import datetime
2
+ import io
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ import click
8
+ import numpy as np
9
+ import PIL.Image
10
+ import uvicorn
11
+ from loguru import logger
12
+
13
+ from osam import __version__
14
+ from osam import _humanize
15
+ from osam import _models
16
+ from osam import _tabulate
17
+ from osam import apis
18
+ from osam import types
19
+
20
+
21
+ @click.group(context_settings=dict(help_option_names=["-h", "--help"]))
22
+ @click.version_option(__version__)
23
+ def cli():
24
+ logger.remove(0)
25
+ logger.add(
26
+ sys.stderr, level="INFO", colorize=True, format="<level>{message}</level>"
27
+ )
28
+ os.makedirs(os.path.expanduser("~/.cache/osam"), exist_ok=True)
29
+ logger.add(
30
+ os.path.expanduser("~/.cache/osam/osam.log"), colorize=True, level="DEBUG"
31
+ )
32
+
33
+
34
+ @cli.command(help="Help about any command")
35
+ @click.argument("subcommand", required=False, type=str)
36
+ @click.pass_context
37
+ def help(ctx, subcommand):
38
+ if subcommand is None:
39
+ click.echo(cli.get_help(ctx))
40
+ return
41
+
42
+ subcommand_obj = cli.get_command(ctx, subcommand)
43
+ if subcommand_obj is None:
44
+ logger.warning("Unknown subcommand {subcommand!r}", subcommand=subcommand)
45
+ click.echo(cli.get_help(ctx))
46
+ else:
47
+ click.echo(subcommand_obj.get_help(ctx))
48
+
49
+
50
+ @cli.command(help="List models")
51
+ @click.option("--all", "-a", "show_all", is_flag=True, help="show all models")
52
+ def list(show_all):
53
+ rows = []
54
+ for model in _models.MODELS:
55
+ size = model.get_size()
56
+ modified_at = model.get_modified_at()
57
+
58
+ if size is None or modified_at is None:
59
+ if show_all:
60
+ size = "<not pulled>"
61
+ modified_at = "<not pulled>"
62
+ else:
63
+ continue
64
+ else:
65
+ size = _humanize.naturalsize(size)
66
+ modified_at = _humanize.naturaltime(
67
+ datetime.datetime.fromtimestamp(modified_at)
68
+ )
69
+
70
+ rows.append([model.name, model.get_id(), size, modified_at])
71
+ click.echo(_tabulate.tabulate(rows, headers=["NAME", "ID", "SIZE", "MODIFIED"]))
72
+
73
+
74
+ @cli.command(help="Pull a model")
75
+ @click.argument("model_name", metavar="model", type=str)
76
+ def pull(model_name):
77
+ for cls in _models.MODELS:
78
+ if cls.name == model_name:
79
+ break
80
+ else:
81
+ logger.warning("Model {model_name!r} not found.", model_name=model_name)
82
+ sys.exit(1)
83
+
84
+ logger.info("Pulling {model_name!r}...", model_name=model_name)
85
+ cls.pull()
86
+ logger.info("Pulled {model_name!r}", model_name=model_name)
87
+
88
+
89
+ @cli.command(help="Remove a model")
90
+ @click.argument("model_name", metavar="model", type=str)
91
+ def rm(model_name):
92
+ for cls in _models.MODELS:
93
+ if cls.name == model_name:
94
+ break
95
+ else:
96
+ logger.warning("Model {model_name} not found.", model_name=model_name)
97
+ sys.exit(1)
98
+
99
+ logger.info("Removing {model_name!r}...", model_name=model_name)
100
+ cls.remove()
101
+ logger.info("Removed {model_name!r}", model_name=model_name)
102
+
103
+
104
+ @cli.command(help="Start server")
105
+ @click.option("--reload", is_flag=True, help="reload server on file changes")
106
+ def serve(reload):
107
+ logger.info("Starting server...")
108
+ uvicorn.run("osam._server:app", host="127.0.0.1", port=11368, reload=reload)
109
+
110
+
111
+ @cli.command(help="Run a model")
112
+ @click.argument("model_name", metavar="model", type=str)
113
+ @click.option(
114
+ "--image",
115
+ "image_path",
116
+ type=click.Path(exists=True),
117
+ help="image path",
118
+ required=True,
119
+ )
120
+ @click.option("--prompt", type=json.loads, help="prompt")
121
+ @click.option("--json", is_flag=True, help="json output")
122
+ def run(model_name: str, image_path: str, prompt, json: bool) -> None:
123
+ try:
124
+ request: types.GenerateRequest = types.GenerateRequest(
125
+ model=model_name,
126
+ image=np.asarray(PIL.Image.open(image_path)),
127
+ prompt=prompt,
128
+ )
129
+ response: types.GenerateResponse = apis.generate(request=request)
130
+ except ValueError as e:
131
+ logger.error("{e}", e=e)
132
+ sys.exit(1)
133
+
134
+ if json:
135
+ click.echo(response.model_dump_json())
136
+ else:
137
+ visualization: np.ndarray = (
138
+ 0.5 * request.image
139
+ + 0.5
140
+ * np.array([0, 255, 0])[None, None, :]
141
+ * (response.mask > 0)[:, :, None]
142
+ ).astype(np.uint8)
143
+ sys.stdout.buffer.write(_image_ndarray_to_data(visualization))
144
+
145
+
146
+ def _image_ndarray_to_data(ndarray: np.ndarray) -> bytes:
147
+ pil = PIL.Image.fromarray(ndarray)
148
+ with io.BytesIO() as f:
149
+ pil.save(f, format="PNG")
150
+ return f.getvalue()
151
+
152
+
153
+ if __name__ == "__main__":
154
+ cli()
@@ -0,0 +1,28 @@
1
+ import datetime
2
+ import math
3
+
4
+
5
+ def naturalsize(size: int) -> str:
6
+ """Convert byte size to human readable format."""
7
+ if size == 0:
8
+ return "0B"
9
+ base = 1024
10
+ prefixes = ["B", "KB", "MB", "GB", "TB", "PB"]
11
+ power = int(math.log(size, base))
12
+ prefix = prefixes[power]
13
+ size /= base**power
14
+ return f"{size:.2f} {prefix}"
15
+
16
+
17
+ def naturaltime(dt: datetime.datetime) -> str:
18
+ """Convert datetime to human readable format."""
19
+ now = datetime.datetime.now()
20
+ delta = now - dt
21
+ if delta.days > 0:
22
+ return f"{delta.days} days ago"
23
+ elif delta.seconds < 60:
24
+ return f"{delta.seconds} seconds ago"
25
+ elif delta.seconds < 3600:
26
+ return f"{delta.seconds // 60} minutes ago"
27
+ else:
28
+ return f"{delta.seconds // 3600} hours ago"
@@ -0,0 +1,19 @@
1
+ import base64
2
+ import io
3
+
4
+ import numpy as np
5
+ import PIL.Image
6
+
7
+
8
+ def image_ndarray_to_b64data(ndarray):
9
+ pil = PIL.Image.fromarray(ndarray)
10
+ f = io.BytesIO()
11
+ pil.save(f, format="PNG")
12
+ data = f.getvalue()
13
+ return base64.b64encode(data).decode("utf-8")
14
+
15
+
16
+ def image_b64data_to_ndarray(b64data):
17
+ data = base64.b64decode(b64data)
18
+ pil = PIL.Image.open(io.BytesIO(data))
19
+ return np.asarray(pil)
@@ -0,0 +1,14 @@
1
+ from ._base import ModelBase # noqa: F401
2
+ from ._efficient_sam import EfficientSam10m
3
+ from ._efficient_sam import EfficientSam25m
4
+ from ._sam import Sam91m
5
+ from ._sam import Sam308m
6
+ from ._sam import Sam636m
7
+
8
+ MODELS = [
9
+ EfficientSam10m,
10
+ EfficientSam25m,
11
+ Sam91m,
12
+ Sam308m,
13
+ Sam636m,
14
+ ]
@@ -0,0 +1,102 @@
1
+ import dataclasses
2
+ import hashlib
3
+ import os
4
+ from typing import Dict
5
+ from typing import Optional
6
+
7
+ import gdown
8
+ import numpy as np
9
+ import onnxruntime
10
+ from loguru import logger
11
+
12
+ from osam import types
13
+
14
+
15
+ @dataclasses.dataclass
16
+ class ModelBlob:
17
+ url: str
18
+ hash: str
19
+
20
+ @property
21
+ def path(self):
22
+ return os.path.expanduser(f"~/.cache/osam/models/blobs/{self.hash}")
23
+
24
+ @property
25
+ def size(self):
26
+ if os.path.exists(self.path):
27
+ return os.stat(self.path).st_size
28
+ else:
29
+ return None
30
+
31
+ @property
32
+ def modified_at(self):
33
+ if os.path.exists(self.path):
34
+ return os.stat(self.path).st_mtime
35
+ else:
36
+ return None
37
+
38
+ def pull(self):
39
+ gdown.cached_download(url=self.url, path=self.path, hash=self.hash)
40
+
41
+ def remove(self):
42
+ if os.path.exists(self.path):
43
+ logger.debug("Removing model blob {path!r}", path=self.path)
44
+ os.remove(self.path)
45
+ else:
46
+ logger.warning("Model blob {path!r} not found", path=self.path)
47
+
48
+
49
+ class ModelBase:
50
+ name: str
51
+
52
+ _blobs: Dict[str, ModelBlob]
53
+ _inference_sessions: Dict[str, onnxruntime.InferenceSession]
54
+
55
+ def __init__(self):
56
+ self.pull()
57
+ self._inference_sessions = {
58
+ key: onnxruntime.InferenceSession(blob.path)
59
+ for key, blob in self._blobs.items()
60
+ }
61
+
62
+ @classmethod
63
+ def pull(cls):
64
+ for blob in cls._blobs.values():
65
+ blob.pull()
66
+
67
+ @classmethod
68
+ def remove(cls):
69
+ for blob in cls._blobs.values():
70
+ blob.remove()
71
+
72
+ @classmethod
73
+ def get_id(cls) -> str:
74
+ return hashlib.md5(
75
+ "+".join(blob.hash for blob in cls._blobs.values()).encode()
76
+ ).hexdigest()[:12]
77
+
78
+ @classmethod
79
+ def get_size(cls) -> Optional[int]:
80
+ size = 0
81
+ for blob in cls._blobs.values():
82
+ if blob.size is None:
83
+ return None
84
+ size += blob.size
85
+ return size
86
+
87
+ @classmethod
88
+ def get_modified_at(cls) -> Optional[float]:
89
+ modified_at = 0
90
+ for blob in cls._blobs.values():
91
+ if blob.modified_at is None:
92
+ return None
93
+ modified_at = max(modified_at, blob.modified_at)
94
+ return modified_at
95
+
96
+ def encode_image(self, image: np.ndarray) -> types.ImageEmbedding:
97
+ raise NotImplementedError
98
+
99
+ def generate_mask(
100
+ self, image_embedding: types.ImageEmbedding, prompt: types.Prompt
101
+ ) -> np.ndarray:
102
+ raise NotImplementedError
@@ -0,0 +1,87 @@
1
+ import numpy as np
2
+
3
+ from osam._models._base import ModelBase
4
+ from osam._models._base import ModelBlob
5
+ from osam.types import ImageEmbedding
6
+ from osam.types import Prompt
7
+
8
+
9
+ class EfficientSam(ModelBase):
10
+ def encode_image(self, image: np.ndarray) -> ImageEmbedding:
11
+ if image.ndim == 2:
12
+ raise ValueError("Grayscale images are not supported")
13
+ if image.ndim == 3 and image.shape[2] == 4:
14
+ raise ValueError("RGBA images are not supported")
15
+
16
+ batched_images = image.transpose(2, 0, 1)[None].astype(np.float32) / 255
17
+ image_embedding = self._inference_sessions["encoder"].run(
18
+ output_names=None,
19
+ input_feed={"batched_images": batched_images},
20
+ )[0][0] # (embedding_dim, height, width)
21
+
22
+ return ImageEmbedding(
23
+ original_height=image.shape[0],
24
+ original_width=image.shape[1],
25
+ embedding=image_embedding,
26
+ )
27
+
28
+ def generate_mask(
29
+ self,
30
+ image_embedding: ImageEmbedding,
31
+ prompt: Prompt,
32
+ ) -> np.ndarray:
33
+ input_point = np.array(prompt.points, dtype=np.float32)
34
+ input_label = np.array(prompt.point_labels, dtype=np.float32)
35
+
36
+ # batch_size, embedding_dim, height, width
37
+ batched_image_embedding = image_embedding.embedding[None, :, :, :]
38
+ # batch_size, num_queries, num_points, 2
39
+ batched_point_coords = input_point[None, None, :, :]
40
+ # batch_size, num_queries, num_points
41
+ batched_point_labels = input_label[None, None, :]
42
+
43
+ decoder_inputs = {
44
+ "image_embeddings": batched_image_embedding,
45
+ "batched_point_coords": batched_point_coords,
46
+ "batched_point_labels": batched_point_labels,
47
+ "orig_im_size": np.array(
48
+ (image_embedding.original_height, image_embedding.original_width),
49
+ dtype=np.int64,
50
+ ),
51
+ }
52
+
53
+ masks, _, _ = self._inference_sessions["decoder"].run(None, decoder_inputs)
54
+ mask = masks[0, 0, 0, :, :] # (1, 1, 3, H, W) -> (H, W)
55
+ mask = mask > 0.0
56
+
57
+ return mask
58
+
59
+
60
+ class EfficientSam10m(EfficientSam):
61
+ name = "efficient-sam:10m"
62
+
63
+ _blobs = {
64
+ "encoder": ModelBlob(
65
+ url="https://github.com/labelmeai/efficient-sam/releases/download/onnx-models-20231225/efficient_sam_vitt_encoder.onnx",
66
+ hash="sha256:7a73ee65aa2c37237c89b4b18e73082f757ffb173899609c5d97a2bbd4ebb02d",
67
+ ),
68
+ "decoder": ModelBlob(
69
+ url="https://github.com/labelmeai/efficient-sam/releases/download/onnx-models-20231225/efficient_sam_vitt_decoder.onnx",
70
+ hash="sha256:e1afe46232c3bfa3470a6a81c7d3181836a94ea89528aff4e0f2d2c611989efd",
71
+ ),
72
+ }
73
+
74
+
75
+ class EfficientSam25m(EfficientSam):
76
+ name = "efficient-sam:25m"
77
+
78
+ _blobs = {
79
+ "encoder": ModelBlob(
80
+ url="https://github.com/labelmeai/efficient-sam/releases/download/onnx-models-20231225/efficient_sam_vits_encoder.onnx",
81
+ hash="sha256:4cacbb23c6903b1acf87f1d77ed806b840800c5fcd4ac8f650cbffed474b8896",
82
+ ),
83
+ "decoder": ModelBlob(
84
+ url="https://github.com/labelmeai/efficient-sam/releases/download/onnx-models-20231225/efficient_sam_vits_decoder.onnx",
85
+ hash="sha256:4727baf23dacfb51d4c16795b2ac382c403505556d0284e84c6ff3d4e8e36f22",
86
+ ),
87
+ }
@@ -0,0 +1,184 @@
1
+ import numpy as np
2
+ import onnxruntime
3
+ import PIL.Image
4
+
5
+ from osam._models._base import ModelBase
6
+ from osam._models._base import ModelBlob
7
+ from osam.types import ImageEmbedding
8
+ from osam.types import Prompt
9
+
10
+
11
+ class Sam(ModelBase):
12
+ _image_size: int = 1024
13
+
14
+ def encode_image(self, image: np.ndarray):
15
+ if image.ndim == 2:
16
+ raise ValueError("Grayscale images are not supported")
17
+ if image.ndim == 3 and image.shape[2] == 4:
18
+ raise ValueError("RGBA images are not supported")
19
+
20
+ image_embedding = _compute_image_embedding(
21
+ encoder_session=self._inference_sessions["encoder"],
22
+ image=image,
23
+ image_size=self._image_size,
24
+ )
25
+
26
+ return ImageEmbedding(
27
+ original_height=image.shape[0],
28
+ original_width=image.shape[1],
29
+ embedding=image_embedding,
30
+ )
31
+
32
+ def generate_mask(
33
+ self,
34
+ image_embedding: ImageEmbedding,
35
+ prompt: Prompt,
36
+ ) -> np.ndarray:
37
+ return _generate_mask(
38
+ decoder_session=self._inference_sessions["decoder"],
39
+ image_embedding=image_embedding,
40
+ prompt=prompt,
41
+ image_size=self._image_size,
42
+ )
43
+
44
+
45
+ def _compute_scale_to_resize_image(height: int, width: int, image_size: int):
46
+ if width > height:
47
+ scale = image_size / width
48
+ new_height = int(round(height * scale))
49
+ new_width = image_size
50
+ else:
51
+ scale = image_size / height
52
+ new_height = image_size
53
+ new_width = int(round(width * scale))
54
+ return scale, new_height, new_width
55
+
56
+
57
+ def _resize_image(image: np.ndarray, image_size: int):
58
+ scale, new_height, new_width = _compute_scale_to_resize_image(
59
+ height=image.shape[0],
60
+ width=image.shape[1],
61
+ image_size=image_size,
62
+ )
63
+ scaled_image = np.asarray(
64
+ PIL.Image.fromarray(image).resize(
65
+ (new_width, new_height), resample=PIL.Image.BILINEAR
66
+ )
67
+ ).astype(np.float32)
68
+ return scale, scaled_image
69
+
70
+
71
+ def _compute_image_embedding(
72
+ encoder_session: onnxruntime.InferenceSession,
73
+ image: np.ndarray,
74
+ image_size: int,
75
+ ):
76
+ scale, x = _resize_image(image=image, image_size=image_size)
77
+ x = (x - np.array([123.675, 116.28, 103.53], dtype=np.float32)) / np.array(
78
+ [58.395, 57.12, 57.375], dtype=np.float32
79
+ )
80
+ x = np.pad(
81
+ x,
82
+ (
83
+ (0, image_size - x.shape[0]),
84
+ (0, image_size - x.shape[1]),
85
+ (0, 0),
86
+ ),
87
+ )
88
+ x = x.transpose(2, 0, 1)[None, :, :, :]
89
+
90
+ output = encoder_session.run(output_names=None, input_feed={"x": x})
91
+ image_embedding = output[0][0] # (embedding_dim, height, width)
92
+ return image_embedding
93
+
94
+
95
+ def _generate_mask(
96
+ decoder_session: onnxruntime.InferenceSession,
97
+ image_embedding: ImageEmbedding,
98
+ prompt: Prompt,
99
+ image_size: int,
100
+ ):
101
+ onnx_coord = np.concatenate([prompt.points, np.array([[0.0, 0.0]])], axis=0)[
102
+ None, :, :
103
+ ]
104
+ onnx_label = np.concatenate([prompt.point_labels, np.array([-1])], axis=0)[
105
+ None, :
106
+ ].astype(np.float32)
107
+
108
+ scale, new_height, new_width = _compute_scale_to_resize_image(
109
+ height=image_embedding.original_height,
110
+ width=image_embedding.original_width,
111
+ image_size=image_size,
112
+ )
113
+ onnx_coord = (
114
+ onnx_coord.astype(float)
115
+ * (
116
+ new_width / image_embedding.original_width,
117
+ new_height / image_embedding.original_height,
118
+ )
119
+ ).astype(np.float32)
120
+
121
+ onnx_mask_input = np.zeros((1, 1, 256, 256), dtype=np.float32)
122
+ onnx_has_mask_input = np.array([-1], dtype=np.float32)
123
+
124
+ decoder_inputs = {
125
+ "image_embeddings": image_embedding.embedding[None, :, :, :],
126
+ "point_coords": onnx_coord,
127
+ "point_labels": onnx_label,
128
+ "mask_input": onnx_mask_input,
129
+ "has_mask_input": onnx_has_mask_input,
130
+ "orig_im_size": np.array(
131
+ (image_embedding.original_height, image_embedding.original_width),
132
+ dtype=np.float32,
133
+ ),
134
+ }
135
+
136
+ masks, _, _ = decoder_session.run(None, decoder_inputs)
137
+ mask = masks[0, 0] # (1, 1, H, W) -> (H, W)
138
+ mask = mask > 0.0
139
+ return mask
140
+
141
+
142
+ class Sam91m(Sam):
143
+ name = "sam:91m"
144
+
145
+ _blobs = {
146
+ "encoder": ModelBlob(
147
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_b_01ec64.quantized.encoder.onnx",
148
+ hash="sha256:3346b9cc551c9902fbf3b203935e933592b22e042365f58321c17fc12641fd6a",
149
+ ),
150
+ "decoder": ModelBlob(
151
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_b_01ec64.quantized.decoder.onnx",
152
+ hash="sha256:edbcf1a0afaa55621fb0abe6b3db1516818b609ea9368f309746a3afc68f7613",
153
+ ),
154
+ }
155
+
156
+
157
+ class Sam308m(Sam):
158
+ name = "sam:308m"
159
+
160
+ _blobs = {
161
+ "encoder": ModelBlob(
162
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_l_0b3195.quantized.encoder.onnx",
163
+ hash="sha256:f7158a4a1fe7f670ef47ea2f7f852685425c1ed6caa40f5df86cbe2b0502034f",
164
+ ),
165
+ "decoder": ModelBlob(
166
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_l_0b3195.quantized.decoder.onnx",
167
+ hash="sha256:552ebb23bf52c5e5b971ac710d1eb8dccfd88b36cc6aff881d1536d1662e6d7b",
168
+ ),
169
+ }
170
+
171
+
172
+ class Sam636m(Sam):
173
+ name = "sam:636m"
174
+
175
+ _blobs = {
176
+ "encoder": ModelBlob(
177
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_h_4b8939.quantized.encoder.onnx",
178
+ hash="sha256:a5c745fd4279efc5e5436b412200e983dafc2249ce172af6cc0002a71bb5f485",
179
+ ),
180
+ "decoder": ModelBlob(
181
+ url="https://github.com/wkentaro/labelme/releases/download/sam-20230416/sam_vit_h_4b8939.quantized.decoder.onnx",
182
+ hash="sha256:020b385a45ffe51097e1acd10cd791075a86171153505f789a793bc382eef210",
183
+ ),
184
+ }
@@ -0,0 +1,33 @@
1
+ import logging
2
+ import os
3
+
4
+ import fastapi
5
+
6
+ from osam import apis
7
+ from osam import types
8
+
9
+ app: fastapi.FastAPI = fastapi.FastAPI()
10
+
11
+
12
+ @app.get("/")
13
+ async def index():
14
+ return fastapi.Response("osam is running")
15
+
16
+
17
+ @app.post("/api/generate")
18
+ async def generate(request: types.GenerateRequest) -> types.GenerateResponse:
19
+ try:
20
+ return apis.generate(request=request)
21
+ except ValueError as e:
22
+ raise fastapi.HTTPException(400, str(e))
23
+
24
+
25
+ # https://github.com/tiangolo/fastapi/issues/1508#issuecomment-638365277
26
+ @app.on_event("startup")
27
+ async def startup_event():
28
+ uvicorn_logger = logging.getLogger("uvicorn")
29
+ handler = logging.FileHandler(os.path.expanduser("~/.cache/osam/osam.log"))
30
+ handler.setLevel(logging.DEBUG)
31
+ fmt = "%(asctime)s [%(levelname)s] %(module)s:%(funcName)s:%(lineno)s - %(message)s"
32
+ handler.setFormatter(logging.Formatter(fmt=fmt))
33
+ uvicorn_logger.addHandler(handler)
@@ -0,0 +1,19 @@
1
+ import collections
2
+
3
+
4
+ def tabulate(rows, headers):
5
+ col_widths = collections.defaultdict(int)
6
+ for row in [headers] + rows:
7
+ for c, item in enumerate(row):
8
+ col_widths[c] = max(col_widths[c], len(item))
9
+
10
+ table = ""
11
+
12
+ for c, header in enumerate(headers):
13
+ table += header.ljust(col_widths[c] + 4)
14
+
15
+ for row in rows:
16
+ table += "\n"
17
+ for c, item in enumerate(row):
18
+ table += item.ljust(col_widths[c] + 4)
19
+ return table
@@ -0,0 +1,40 @@
1
+ from typing import Optional
2
+
3
+ import numpy as np
4
+ from loguru import logger
5
+
6
+ from osam import _models
7
+ from osam import types
8
+
9
+ model: Optional[_models.ModelBase] = None
10
+
11
+
12
+ def generate(request: types.GenerateRequest) -> types.GenerateResponse:
13
+ global model
14
+
15
+ if model is None or model.name != request.model:
16
+ for model_cls in _models.MODELS:
17
+ if model_cls.name == request.model:
18
+ model = model_cls()
19
+ break
20
+ else:
21
+ raise ValueError(f"Model not found: {request.model!r}")
22
+
23
+ image: np.ndarray = request.image
24
+
25
+ if request.prompt is None:
26
+ height, width = image.shape[:2]
27
+ request.prompt = types.Prompt(
28
+ points=np.array([[width / 2, height / 2]], dtype=np.float32),
29
+ point_labels=np.array([1], dtype=np.int32),
30
+ )
31
+ logger.warning(
32
+ "Prompt is not given, so using the center point as prompt: {prompt!r}",
33
+ prompt=request.prompt,
34
+ )
35
+
36
+ image_embedding: types.ImageEmbedding = model.encode_image(image=image)
37
+ mask: np.ndarray = model.generate_mask(
38
+ image_embedding=image_embedding, prompt=request.prompt
39
+ )
40
+ return types.GenerateResponse(model=request.model, mask=mask)
@@ -0,0 +1,86 @@
1
+ from typing import List
2
+ from typing import Optional
3
+ from typing import Union
4
+
5
+ import numpy as np
6
+ import pydantic
7
+
8
+ from osam import _json
9
+
10
+
11
+ class ImageEmbedding(pydantic.BaseModel):
12
+ model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
13
+
14
+ original_height: int
15
+ original_width: int
16
+ embedding: np.ndarray
17
+
18
+ @pydantic.validator("embedding")
19
+ def validate_embedding(cls, embedding):
20
+ if embedding.ndim != 3:
21
+ raise ValueError(
22
+ "embedding must be 3-dimensional: (embedding_dim, height, width)"
23
+ )
24
+ return embedding
25
+
26
+
27
+ class Prompt(pydantic.BaseModel):
28
+ model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
29
+
30
+ points: np.ndarray
31
+ point_labels: np.ndarray
32
+
33
+ @pydantic.validator("points", pre=True)
34
+ def validate_points(cls, points):
35
+ if isinstance(points, list):
36
+ points = np.array(points, dtype=float)
37
+ if points.ndim != 2:
38
+ raise ValueError("points must be 2-dimensional")
39
+ if points.shape[1] != 2:
40
+ raise ValueError("points must have 2 columns")
41
+ return points
42
+
43
+ @pydantic.field_serializer("points")
44
+ def serialize_points(self, points: np.ndarray) -> List[List[float]]:
45
+ return points.tolist()
46
+
47
+ @pydantic.validator("point_labels", pre=True)
48
+ def validate_point_labels(cls, point_labels, values):
49
+ if isinstance(point_labels, list):
50
+ point_labels = np.array(point_labels, dtype=int)
51
+ if point_labels.ndim != 1:
52
+ raise ValueError("point_labels must be 1-dimensional")
53
+ if "points" in values and point_labels.shape[0] != values["points"].shape[0]:
54
+ raise ValueError("point_labels must have the same number of rows as points")
55
+ if not set(np.unique(point_labels).tolist()).issubset({0, 1}):
56
+ raise ValueError("point_labels must contain only 0s and 1s")
57
+ return point_labels
58
+
59
+ @pydantic.field_serializer("point_labels")
60
+ def serialize_point_labels(self, point_labels: np.ndarray) -> List[int]:
61
+ return point_labels.tolist()
62
+
63
+
64
+ class GenerateRequest(pydantic.BaseModel):
65
+ model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
66
+
67
+ model: str
68
+ image: np.ndarray
69
+ prompt: Optional[Prompt] = pydantic.Field(default=None)
70
+
71
+ @pydantic.validator("image", pre=True)
72
+ def validate_image(cls, image: Union[str, np.ndarray]) -> np.ndarray:
73
+ if isinstance(image, str):
74
+ return _json.image_b64data_to_ndarray(b64data=image)
75
+ return image
76
+
77
+
78
+ class GenerateResponse(pydantic.BaseModel):
79
+ model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
80
+
81
+ model: str
82
+ mask: np.ndarray
83
+
84
+ @pydantic.field_serializer("mask")
85
+ def serialize_mask(self, mask: np.ndarray) -> str:
86
+ return _json.image_ndarray_to_b64data(ndarray=mask)
@@ -0,0 +1,102 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.20.0", "hatch-vcs", "hatch-fancy-pypi-readme"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "osam"
7
+ description = "Get up and running vision foundational models locally."
8
+ license = { text = "MIT" }
9
+ requires-python = ">=3.8"
10
+ authors = [
11
+ { name = "Kentaro Wada", email = "www.kentaro.wada@gmail.com" },
12
+ ]
13
+ keywords = []
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ ]
29
+ dependencies = [
30
+ "click",
31
+ "gdown>=5.1.0",
32
+ "fastapi",
33
+ "loguru",
34
+ "onnxruntime",
35
+ "Pillow",
36
+ "uvicorn",
37
+ ]
38
+ dynamic = ["readme", "version"]
39
+
40
+ [project.optional-dependencies]
41
+ test = [
42
+ "build",
43
+ "mypy",
44
+ "types-Pillow",
45
+ "pytest",
46
+ "pytest-xdist",
47
+ "ruff",
48
+ "twine",
49
+ ]
50
+
51
+ [project.urls]
52
+ Homepage = "https://github.com/wkentaro/osam"
53
+
54
+ [tool.hatch.metadata.hooks.fancy-pypi-readme]
55
+ content-type = "text/markdown"
56
+ fragments = [
57
+ { path = "README.md" },
58
+ ]
59
+
60
+ [tool.hatch.version]
61
+ source = "vcs"
62
+
63
+ [project.scripts]
64
+ osam = "osam:__main__.cli"
65
+
66
+ [tool.mypy]
67
+ ignore_missing_imports = true
68
+
69
+ [tool.ruff]
70
+ exclude = [
71
+ ".conda",
72
+ ".git",
73
+ "src",
74
+ ]
75
+
76
+ line-length = 88
77
+ indent-width = 4
78
+
79
+ [tool.ruff.lint]
80
+ # Enable Pyflakes (`F`), pycodestyle (`E`), isort (`I`).
81
+ select = ["E", "F", "I"]
82
+ ignore = []
83
+
84
+ # Allow fix for all enabled rules (when `--fix`) is provided.
85
+ fixable = ["ALL"]
86
+ unfixable = []
87
+
88
+ [tool.ruff.format]
89
+ # Like Black, use double quotes for strings.
90
+ quote-style = "double"
91
+
92
+ # Like Black, indent with spaces, rather than tabs.
93
+ indent-style = "space"
94
+
95
+ # Like Black, respect magic trailing commas.
96
+ skip-magic-trailing-comma = false
97
+
98
+ # Like Black, automatically detect the appropriate line ending.
99
+ line-ending = "auto"
100
+
101
+ [tool.ruff.isort]
102
+ force-single-line = true
@@ -0,0 +1,15 @@
1
+ import io
2
+ import os
3
+ import shlex
4
+ import subprocess
5
+
6
+ import PIL.Image
7
+
8
+ here = os.path.dirname(os.path.abspath(__file__))
9
+
10
+
11
+ def test_run():
12
+ cmd = f"osam run efficient-sam:10m --image {here}/../examples/_images/dogs.jpg"
13
+ output = subprocess.check_output(shlex.split(cmd))
14
+ image = PIL.Image.open(io.BytesIO(output))
15
+ assert image.size == (2560, 1600)