osam 0.1.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.
osam/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version("osam")
osam/__main__.py ADDED
@@ -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()
osam/_humanize.py ADDED
@@ -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"
osam/_json.py ADDED
@@ -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
+ ]
osam/_models/_base.py ADDED
@@ -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
+ }
osam/_models/_sam.py ADDED
@@ -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
+ }
osam/_server.py ADDED
@@ -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)
osam/_tabulate.py ADDED
@@ -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
osam/apis.py ADDED
@@ -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)
osam/types.py ADDED
@@ -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,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
@@ -0,0 +1,17 @@
1
+ osam/__init__.py,sha256=nhsH7gRrl88ggUw8wQ9BO22UPqA9dyo6SwCtIjZ77PQ,76
2
+ osam/__main__.py,sha256=u8Zwb09o-JGItvnY6b7DFO2y_D2So_tb8xENy_qTEsE,4527
3
+ osam/_humanize.py,sha256=2u9-WfL3QvJsD6TMkVH6h4Fko4IpYDTVdRrTf_CACZs,789
4
+ osam/_json.py,sha256=FwPCZP91AeHSbPwKV0LIQVePUKSNTwyaRK4TMDfYFDo,414
5
+ osam/_server.py,sha256=Tn8PSF--lEpzis-lDkPhmNHEVj_k0LFd9WiLFp_OLnc,924
6
+ osam/_tabulate.py,sha256=rIvjVx5vzwUDvKmQT-whdy1GGmV-wDBzZppFX2MMf44,484
7
+ osam/apis.py,sha256=GtXIPxjvW61VeEDuSK6c93BWg1Zm3pRjSFUWjLuiaM4,1239
8
+ osam/types.py,sha256=R3c5MN8gVm-GbR4im9kL0WtXJcL0GhlL0XO1nJOvlSY,2852
9
+ osam/_models/__init__.py,sha256=tgUX6W9buglKjZe-UHZDsN34QgtmNF84K1Zkrp3K1w8,302
10
+ osam/_models/_base.py,sha256=7GRKZdpVYE7vDm1d3pXRpPb2ng38uTDINPXJurjXr4g,2543
11
+ osam/_models/_efficient_sam.py,sha256=piBjzJiKSugLbjSADrUsdejYDyka1jKLn_bivyaXovs,3357
12
+ osam/_models/_sam.py,sha256=MhZJP3InfMY8OimbSYEpKdJEUW2cU-LagfbOGZw90ko,5962
13
+ osam-0.1.0.dist-info/METADATA,sha256=1T-AendpddL7p_xvSZbpXkGw9ZewBpKzmngCrHxDrXk,3487
14
+ osam-0.1.0.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87
15
+ osam-0.1.0.dist-info/entry_points.txt,sha256=eYQwZ5014JIarOx52NTzOpEKdaEzOXgpee8Ia1QFwxQ,43
16
+ osam-0.1.0.dist-info/licenses/LICENSE,sha256=-V0EgZK-ZzX3HurTWJbVeXcDypkjBOtMapASY652D0A,1070
17
+ osam-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.21.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ osam = osam:__main__.cli
@@ -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.