Nightfury16 Claude Opus 4.6 commited on
Commit
a60daf2
Β·
1 Parent(s): 3aa023a

Add Pillow fallback when pyvips/libvips unavailable

Browse files

pyvips fails on HF Spaces if libvips shared lib is missing.
Now tries pyvips at import time, falls back to Pillow+numpy
with the same concurrent fetching and vectorised normalisation.
Both paths share urllib3 connection pooling and ThreadPoolExecutor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +109 -42
  2. packages.txt +2 -0
  3. requirements.txt +1 -0
app.py CHANGED
@@ -1,6 +1,5 @@
1
  import torch
2
  import numpy as np
3
- import pyvips
4
  import gradio as gr
5
  from fastapi import FastAPI, HTTPException
6
  from pydantic import BaseModel
@@ -12,6 +11,17 @@ import urllib3
12
  import os
13
  import json
14
  import random
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  from model import MobileCLIPRanker
17
 
@@ -83,42 +93,87 @@ if DEVICE == "cuda" and hasattr(torch, "compile"):
83
  pass
84
 
85
 
86
- # ── Image processing (pyvips) ──────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def _fetch_and_preprocess(url: str):
88
  """Fetch one image, letterbox-resize, normalise -> CHW float32 numpy."""
89
  try:
90
  if url.startswith("http"):
91
- resp = http_pool.request("GET", url, preload_content=True)
92
- if resp.status != 200:
93
  return None
94
- # thumbnail_buffer uses shrink-on-load (fast JPEG decode)
95
- img = pyvips.Image.thumbnail_buffer(
96
- resp.data, IMG_SIZE, height=IMG_SIZE
97
- )
98
  else:
99
- img = pyvips.Image.thumbnail(url, IMG_SIZE, height=IMG_SIZE)
100
-
101
- # Ensure 3-band sRGB
102
- if img.bands == 4:
103
- img = img.flatten(background=[128, 128, 128])
104
- elif img.bands == 1:
105
- img = img.colourspace("srgb")
106
-
107
- # Letterbox pad to exact IMG_SIZE x IMG_SIZE
108
- if img.width != IMG_SIZE or img.height != IMG_SIZE:
109
- img = img.gravity(
110
- "centre", IMG_SIZE, IMG_SIZE,
111
- extend="background", background=[128, 128, 128],
112
- )
 
 
 
 
 
 
 
 
 
 
 
113
 
114
- # -> float32 CHW normalised numpy
115
- arr = np.ndarray(
116
- buffer=img.write_to_memory(),
117
- dtype=np.uint8,
118
- shape=(IMG_SIZE, IMG_SIZE, 3),
119
- )
120
  arr = (arr.astype(np.float32) * (1.0 / 255.0) - MEAN) * INV_STD
121
- return arr.transpose(2, 0, 1) # HWC -> CHW
122
  except Exception:
123
  return None
124
 
@@ -127,19 +182,31 @@ def _fetch_display(url: str):
127
  """Fetch image for Gradio display -> numpy uint8 HWC."""
128
  try:
129
  if url.startswith("http"):
130
- resp = http_pool.request("GET", url, preload_content=True)
131
- img = pyvips.Image.new_from_buffer(resp.data, "")
 
132
  else:
133
- img = pyvips.Image.new_from_file(url, access="sequential")
134
- if img.bands == 4:
135
- img = img.flatten(background=[255, 255, 255])
136
- elif img.bands == 1:
137
- img = img.colourspace("srgb")
138
- return np.ndarray(
139
- buffer=img.write_to_memory(),
140
- dtype=np.uint8,
141
- shape=(img.height, img.width, 3),
142
- )
 
 
 
 
 
 
 
 
 
 
 
143
  except Exception:
144
  return None
145
 
@@ -224,7 +291,7 @@ def gradio_wrapper(text_input):
224
 
225
  with gr.Blocks() as demo:
226
  gr.Markdown("# Real Estate Image Ranker")
227
- gr.Markdown("**MobileCLIP2-L14** fine-tuned ranker with pyvips acceleration.")
228
  with gr.Row():
229
  with gr.Column(scale=1):
230
  gr.Markdown("### 1. Select Data")
 
1
  import torch
2
  import numpy as np
 
3
  import gradio as gr
4
  from fastapi import FastAPI, HTTPException
5
  from pydantic import BaseModel
 
11
  import os
12
  import json
13
  import random
14
+ from io import BytesIO
15
+
16
+ # ── pyvips with Pillow fallback ─────────────────────────────────────────
17
+ try:
18
+ import pyvips
19
+ USE_VIPS = True
20
+ print("Using pyvips for image processing.")
21
+ except Exception:
22
+ USE_VIPS = False
23
+ from PIL import Image, ImageOps
24
+ print("pyvips not available, falling back to Pillow.")
25
 
26
  from model import MobileCLIPRanker
27
 
 
93
  pass
94
 
95
 
96
+ # ── Image processing ────────────────────────────────────────────────────
97
+ def _fetch_bytes(url: str):
98
+ """Fetch raw bytes from URL via connection-pooled HTTP."""
99
+ resp = http_pool.request("GET", url, preload_content=True)
100
+ if resp.status != 200:
101
+ return None
102
+ return resp.data
103
+
104
+
105
+ def _preprocess_vips(data: bytes):
106
+ """pyvips path: shrink-on-load thumbnail + letterbox pad."""
107
+ img = pyvips.Image.thumbnail_buffer(data, IMG_SIZE, height=IMG_SIZE)
108
+ if img.bands == 4:
109
+ img = img.flatten(background=[128, 128, 128])
110
+ elif img.bands == 1:
111
+ img = img.colourspace("srgb")
112
+ if img.width != IMG_SIZE or img.height != IMG_SIZE:
113
+ img = img.gravity(
114
+ "centre", IMG_SIZE, IMG_SIZE,
115
+ extend="background", background=[128, 128, 128],
116
+ )
117
+ arr = np.ndarray(
118
+ buffer=img.write_to_memory(), dtype=np.uint8,
119
+ shape=(IMG_SIZE, IMG_SIZE, 3),
120
+ )
121
+ return arr
122
+
123
+
124
+ def _preprocess_pil(data: bytes):
125
+ """Pillow path: thumbnail + letterbox pad."""
126
+ img = Image.open(BytesIO(data)).convert("RGB")
127
+ img.thumbnail((IMG_SIZE, IMG_SIZE), Image.Resampling.BICUBIC)
128
+ delta_w = IMG_SIZE - img.size[0]
129
+ delta_h = IMG_SIZE - img.size[1]
130
+ padding = (delta_w // 2, delta_h // 2,
131
+ delta_w - delta_w // 2, delta_h - delta_h // 2)
132
+ img = ImageOps.expand(img, padding, fill=(128, 128, 128))
133
+ return np.asarray(img, dtype=np.uint8)
134
+
135
+
136
+ _preprocess = _preprocess_vips if USE_VIPS else _preprocess_pil
137
+
138
+
139
  def _fetch_and_preprocess(url: str):
140
  """Fetch one image, letterbox-resize, normalise -> CHW float32 numpy."""
141
  try:
142
  if url.startswith("http"):
143
+ data = _fetch_bytes(url)
144
+ if data is None:
145
  return None
146
+ arr = _preprocess(data)
 
 
 
147
  else:
148
+ if USE_VIPS:
149
+ img = pyvips.Image.thumbnail(url, IMG_SIZE, height=IMG_SIZE)
150
+ if img.bands == 4:
151
+ img = img.flatten(background=[128, 128, 128])
152
+ elif img.bands == 1:
153
+ img = img.colourspace("srgb")
154
+ if img.width != IMG_SIZE or img.height != IMG_SIZE:
155
+ img = img.gravity(
156
+ "centre", IMG_SIZE, IMG_SIZE,
157
+ extend="background", background=[128, 128, 128],
158
+ )
159
+ arr = np.ndarray(
160
+ buffer=img.write_to_memory(), dtype=np.uint8,
161
+ shape=(IMG_SIZE, IMG_SIZE, 3),
162
+ )
163
+ else:
164
+ img = Image.open(url).convert("RGB")
165
+ img.thumbnail((IMG_SIZE, IMG_SIZE), Image.Resampling.BICUBIC)
166
+ dw = IMG_SIZE - img.size[0]
167
+ dh = IMG_SIZE - img.size[1]
168
+ img = ImageOps.expand(
169
+ img, (dw // 2, dh // 2, dw - dw // 2, dh - dh // 2),
170
+ fill=(128, 128, 128),
171
+ )
172
+ arr = np.asarray(img, dtype=np.uint8)
173
 
174
+ # float32 CHW normalised
 
 
 
 
 
175
  arr = (arr.astype(np.float32) * (1.0 / 255.0) - MEAN) * INV_STD
176
+ return arr.transpose(2, 0, 1)
177
  except Exception:
178
  return None
179
 
 
182
  """Fetch image for Gradio display -> numpy uint8 HWC."""
183
  try:
184
  if url.startswith("http"):
185
+ data = _fetch_bytes(url)
186
+ if data is None:
187
+ return None
188
  else:
189
+ data = None
190
+
191
+ if USE_VIPS:
192
+ if data is not None:
193
+ img = pyvips.Image.new_from_buffer(data, "")
194
+ else:
195
+ img = pyvips.Image.new_from_file(url, access="sequential")
196
+ if img.bands == 4:
197
+ img = img.flatten(background=[255, 255, 255])
198
+ elif img.bands == 1:
199
+ img = img.colourspace("srgb")
200
+ return np.ndarray(
201
+ buffer=img.write_to_memory(), dtype=np.uint8,
202
+ shape=(img.height, img.width, 3),
203
+ )
204
+ else:
205
+ if data is not None:
206
+ img = Image.open(BytesIO(data)).convert("RGB")
207
+ else:
208
+ img = Image.open(url).convert("RGB")
209
+ return np.asarray(img, dtype=np.uint8)
210
  except Exception:
211
  return None
212
 
 
291
 
292
  with gr.Blocks() as demo:
293
  gr.Markdown("# Real Estate Image Ranker")
294
+ gr.Markdown("**MobileCLIP2-L14** fine-tuned ranker.")
295
  with gr.Row():
296
  with gr.Column(scale=1):
297
  gr.Markdown("### 1. Select Data")
packages.txt CHANGED
@@ -1 +1,3 @@
 
1
  libvips-dev
 
 
1
+ libvips
2
  libvips-dev
3
+ libvips-tools
requirements.txt CHANGED
@@ -8,5 +8,6 @@ pyyaml
8
  huggingface_hub
9
  timm
10
  open_clip_torch
 
11
  pyvips
12
  urllib3
 
8
  huggingface_hub
9
  timm
10
  open_clip_torch
11
+ pillow
12
  pyvips
13
  urllib3