HanningChen commited on
Commit
398e700
·
1 Parent(s): e4172fe
Files changed (3) hide show
  1. models/ScoreFunction_HDC.py +106 -0
  2. models/TaskCLIP.py +46 -2
  3. webui/app.py +123 -16
models/ScoreFunction_HDC.py CHANGED
@@ -4,6 +4,42 @@ import copy
4
  import torch.nn.functional as F
5
  from torch.nn import Parameter
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  class HDReason(torch.nn.Module):
8
  def __init__(self, d=10, D=256):
9
  super().__init__()
@@ -21,6 +57,7 @@ class HDReason(torch.nn.Module):
21
  self.activation0 = torch.nn.ReLU()
22
  self.activation1 = torch.nn.ReLU()
23
 
 
24
  def forward(self, x):
25
  #NOTE: build adjacency graph
26
  q = self.activation1(self.HDC_encoder(self.activation0(self.q_proj(x))))
@@ -34,7 +71,48 @@ class HDReason(torch.nn.Module):
34
  out = adj @ v
35
  out = out*0.3 + 0.7*self.HDC_encoder(self.activation0(self.Linear(x)))
36
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
 
 
 
 
 
 
 
38
 
39
  class ScoreFunctionHDC(torch.nn.Module):
40
  def __init__(self, N_words=20, HDV_D=512) -> None:
@@ -50,6 +128,7 @@ class ScoreFunctionHDC(torch.nn.Module):
50
  self.Activation2 = torch.nn.Sigmoid()
51
  self.register_parameter('bias',Parameter(torch.zeros(1)))
52
 
 
53
  def forward(self, x):
54
  #NOTE: input has shape NxN_word
55
  #NOTE: N_bbox x N_word
@@ -62,4 +141,31 @@ class ScoreFunctionHDC(torch.nn.Module):
62
  output = self.Activation1(output)
63
  output = self.Linear4(output) + self.bias
64
  output = self.Activation2(output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  return output
 
4
  import torch.nn.functional as F
5
  from torch.nn import Parameter
6
 
7
+ def _fake_quant_sym(x: torch.Tensor, bits: int, eps: float = 1e-8) -> torch.Tensor:
8
+ bits = int(bits)
9
+ if bits >= 32:
10
+ return x
11
+ if bits == 16:
12
+ # pick fp16; if you prefer bf16: x.to(torch.bfloat16).to(torch.float32)
13
+ return x.to(torch.float16).to(torch.float32)
14
+ if bits == 1:
15
+ return torch.sign(x)
16
+
17
+ # signed symmetric levels: [-Qmax, Qmax]
18
+ Qmax = (1 << (bits - 1)) - 1
19
+ # per-row scale (last dim); works for both (N,d) and (...,d)
20
+ max_abs = x.abs().amax(dim=-1, keepdim=True).clamp(min=eps)
21
+ scale = max_abs / Qmax
22
+ q = torch.round(x / scale).clamp(-Qmax, Qmax)
23
+ return (q * scale).to(x.dtype)
24
+
25
+ def qlinear(x: torch.Tensor, layer: torch.nn.Linear, bits: int) -> torch.Tensor:
26
+ """Quantize BOTH activation and weight, then do linear in float."""
27
+ if int(bits) >= 32:
28
+ return layer(x)
29
+ if int(bits) == 16:
30
+ # do true fp16 compute-ish (still uses PyTorch kernels)
31
+ x16 = x.to(torch.float16)
32
+ w16 = layer.weight.to(torch.float16)
33
+ b16 = None if layer.bias is None else layer.bias.to(torch.float16)
34
+ y16 = F.linear(x16, w16, b16)
35
+ return y16.to(torch.float32)
36
+
37
+ xq = _fake_quant_sym(x, bits)
38
+ wq = _fake_quant_sym(layer.weight, bits)
39
+ b = layer.bias # keep bias float (common & stable)
40
+ y = F.linear(xq, wq, b)
41
+ return _fake_quant_sym(y, bits)
42
+
43
  class HDReason(torch.nn.Module):
44
  def __init__(self, d=10, D=256):
45
  super().__init__()
 
57
  self.activation0 = torch.nn.ReLU()
58
  self.activation1 = torch.nn.ReLU()
59
 
60
+ """
61
  def forward(self, x):
62
  #NOTE: build adjacency graph
63
  q = self.activation1(self.HDC_encoder(self.activation0(self.q_proj(x))))
 
71
  out = adj @ v
72
  out = out*0.3 + 0.7*self.HDC_encoder(self.activation0(self.Linear(x)))
73
  return out
74
+ """
75
+ def forward(self, x, quant_bits: int = 32):
76
+ b = int(quant_bits)
77
+
78
+ # q path
79
+ q = qlinear(x, self.q_proj, b)
80
+ q = self.activation0(q)
81
+ q = qlinear(q, self.HDC_encoder, b)
82
+ q = self.activation1(q)
83
+
84
+ # k path
85
+ k = qlinear(x, self.k_proj, b)
86
+ k = self.activation0(k)
87
+ k = qlinear(k, self.HDC_encoder, b)
88
+ k = self.activation1(k)
89
+
90
+ q = _fake_quant_sym(q * self.scale, b)
91
+ k = _fake_quant_sym(k, b)
92
+
93
+ # adj matmul + softmax
94
+ adj = _fake_quant_sym(q @ k.transpose(-2, -1), b)
95
+
96
+ # softmax is sensitive at low-bit; keep it in fp32 but quantize output
97
+ adj = adj.softmax(dim=-1)
98
+ adj = _fake_quant_sym(adj, b)
99
+
100
+ # v path
101
+ v = qlinear(x, self.v_proj, b)
102
+ v = self.activation0(v)
103
+ v = qlinear(v, self.HDC_encoder, b)
104
+ v = self.activation1(v)
105
+ v = _fake_quant_sym(v, b)
106
+
107
+ out = _fake_quant_sym(adj @ v, b)
108
 
109
+ # skip/mix branch
110
+ base = qlinear(x, self.Linear, b)
111
+ base = self.activation0(base)
112
+ base = qlinear(base, self.HDC_encoder, b)
113
+
114
+ out = _fake_quant_sym(out * 0.3 + 0.7 * base, b)
115
+ return out
116
 
117
  class ScoreFunctionHDC(torch.nn.Module):
118
  def __init__(self, N_words=20, HDV_D=512) -> None:
 
128
  self.Activation2 = torch.nn.Sigmoid()
129
  self.register_parameter('bias',Parameter(torch.zeros(1)))
130
 
131
+ """
132
  def forward(self, x):
133
  #NOTE: input has shape NxN_word
134
  #NOTE: N_bbox x N_word
 
141
  output = self.Activation1(output)
142
  output = self.Linear4(output) + self.bias
143
  output = self.Activation2(output)
144
+ return output
145
+ """
146
+ def forward(self, x, quant_bits: int = 32):
147
+ b = int(quant_bits)
148
+
149
+ # input activation quant (optional but consistent)
150
+ if b < 32:
151
+ x = _fake_quant_sym(x, b)
152
+
153
+ output = self.HDReason(x, quant_bits=b)
154
+ output = self.norm(output) # LayerNorm usually best left fp32
155
+ output = self.Activation1(output)
156
+ if b < 16:
157
+ output = _fake_quant_sym(output, b)
158
+
159
+ output = qlinear(output, self.Linear2, b)
160
+ output = self.Activation1(output)
161
+ if b < 16:
162
+ output = _fake_quant_sym(output, b)
163
+
164
+ output = qlinear(output, self.Linear3, b)
165
+ output = self.Activation1(output)
166
+ if b < 16:
167
+ output = _fake_quant_sym(output, b)
168
+
169
+ output = qlinear(output, self.Linear4, b) + self.bias
170
+ output = self.Activation2(output)
171
  return output
models/TaskCLIP.py CHANGED
@@ -55,13 +55,52 @@ class TaskCLIP(torch.nn.Module):
55
  self.glob_adapter = torch.nn.MultiheadAttention(self.d_model,
56
  self.nhead,
57
  dropout=self.dropout)
 
58
  if model_config['score_function'] != 'HDC':
59
  self.ScoreFunction = ScoreFunction(N_words=self.N_words)
60
  else:
61
  self.ScoreFunction = ScoreFunctionHDC(N_words=self.N_words, HDV_D=int(model_config['HDV_D']))
62
  self.threshold = 0.1
63
 
64
- def forward(self, tgt, memory, image_embedding,norm=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  if self.norm_before:
66
  tgt /= tgt.norm(dim=-1, keepdim=True)
67
  memory /= memory.norm(dim=-1, keepdim=True)
@@ -74,9 +113,14 @@ class TaskCLIP(torch.nn.Module):
74
  tgt = self.ratio_glob*self.glob_adapter(tgt, image_embedding_temp, image_embedding_temp)[0] + (1 - self.ratio_glob)*tgt
75
  tgt_new, memory_new = self.decoder(tgt,memory,None)
76
  score_raw = torch.mm(tgt_new,memory_new.T)
 
 
77
  if self.norm_after:
78
  score_raw = self.Norm(score_raw)
79
- score_res = self.ScoreFunction(score_raw)
 
 
 
80
  return tgt_new, memory_new, score_res, score_raw
81
 
82
  def Norm(self, score):
 
55
  self.glob_adapter = torch.nn.MultiheadAttention(self.d_model,
56
  self.nhead,
57
  dropout=self.dropout)
58
+ self.score_function_name = model_config["score_function"]
59
  if model_config['score_function'] != 'HDC':
60
  self.ScoreFunction = ScoreFunction(N_words=self.N_words)
61
  else:
62
  self.ScoreFunction = ScoreFunctionHDC(N_words=self.N_words, HDV_D=int(model_config['HDV_D']))
63
  self.threshold = 0.1
64
 
65
+ def _apply_hw_noise(self, score_raw: torch.Tensor, dist: str, width_0_100: int, strength_0_100: int) -> torch.Tensor:
66
+ dist = (dist or "none").lower()
67
+ w = max(0, min(100, int(width_0_100)))
68
+ s = max(0, min(100, int(strength_0_100)))
69
+
70
+ if dist == "none" or w == 0 or s == 0:
71
+ return score_raw
72
+
73
+ # Tune this constant to match your desired “device noise” magnitude.
74
+ # score_raw here is a dot-product similarity matrix; typical scale depends on your embeddings.
75
+ MAX_WIDTH = 5.0
76
+
77
+ base = (w / 100.0) * MAX_WIDTH
78
+ scale = (s / 100.0)
79
+ eps = base * scale
80
+
81
+ if dist == "gaussian":
82
+ noise = torch.randn_like(score_raw) * eps
83
+ elif dist == "uniform":
84
+ noise = (torch.rand_like(score_raw) * 2.0 - 1.0) * eps
85
+ elif dist == "laplace":
86
+ # Laplace(0, b): sample via inverse-CDF
87
+ u = torch.rand_like(score_raw) - 0.5
88
+ noise = -eps * torch.sign(u) * torch.log1p(-2.0 * torch.abs(u))
89
+ else:
90
+ return score_raw
91
+
92
+ return score_raw + noise
93
+
94
+ def forward(
95
+ self,
96
+ tgt,
97
+ memory,
98
+ image_embedding,
99
+ norm=False,
100
+ hw_noise_dist: str = "none",
101
+ hw_noise_width: int = 0,
102
+ hw_noise_strength: int = 0,
103
+ hdc_bits: int = 32):
104
  if self.norm_before:
105
  tgt /= tgt.norm(dim=-1, keepdim=True)
106
  memory /= memory.norm(dim=-1, keepdim=True)
 
113
  tgt = self.ratio_glob*self.glob_adapter(tgt, image_embedding_temp, image_embedding_temp)[0] + (1 - self.ratio_glob)*tgt
114
  tgt_new, memory_new = self.decoder(tgt,memory,None)
115
  score_raw = torch.mm(tgt_new,memory_new.T)
116
+ # add noise
117
+ score_raw = self._apply_hw_noise(score_raw, hw_noise_dist, hw_noise_width, hw_noise_strength)
118
  if self.norm_after:
119
  score_raw = self.Norm(score_raw)
120
+ if self.score_function_name == 'HDC':
121
+ score_res = self.ScoreFunction(score_raw, quant_bits=hdc_bits)
122
+ else:
123
+ score_res = self.ScoreFunction(score_raw)
124
  return tgt_new, memory_new, score_res, score_raw
125
 
126
  def Norm(self, score):
webui/app.py CHANGED
@@ -1,8 +1,13 @@
1
  import os
2
  import uuid
 
 
3
  from pathlib import Path
4
 
5
- import traceback
 
 
 
6
  from fastapi import FastAPI, Request, UploadFile, File, Form
7
  from fastapi.responses import HTMLResponse, JSONResponse
8
  from fastapi.staticfiles import StaticFiles
@@ -59,13 +64,105 @@ DEFAULT_OD = "xlarge"
59
  DEFAULT_SAM_CKPT = str(CKPT_DIR / "sam2.1_l.pt")
60
  DEFAULT_IMAGEBIND_CKPT = str(CKPT_DIR / "imagebind_huge.pth") # optional but recommended
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # ---- Load runner ONCE at startup ----
 
 
 
 
 
 
63
  runner = ModelRunner(
64
  project_root=str(PROJECT_ROOT),
65
- device=os.getenv("DEVICE", "cuda:0"),
66
  yolo_ckpt=OD_VALUE_TO_CKPT[DEFAULT_OD],
67
  sam_ckpt=DEFAULT_SAM_CKPT,
68
- imagebind_ckpt=DEFAULT_IMAGEBIND_CKPT, # if missing, runner can fall back to pretrained=True
69
  id2task_name_file="./id2task_name.json",
70
  task2prompt_file="./task20.json",
71
  threshold=0.01,
@@ -74,6 +171,7 @@ runner = ModelRunner(
74
  forward_thre=0.1,
75
  )
76
 
 
77
  @app.get("/", response_class=HTMLResponse)
78
  def index(request: Request):
79
  return templates.TemplateResponse(
@@ -92,14 +190,18 @@ def index(request: Request):
92
  },
93
  )
94
 
 
95
  @app.post("/api/run")
96
  async def api_run(
 
97
  vlm_model: str = Form(DEFAULT_VLM),
98
  od_model: str = Form(DEFAULT_OD),
99
  task_id: int = Form(1),
100
  score_function: str = Form(DEFAULT_SCORE_FUNC),
101
  hdv_dim: int = Form(DEFAULT_HDV),
102
  viz_mode: str = Form("bbox"),
 
 
103
  upload: UploadFile = File(...),
104
  ):
105
  # validate + pick decoder
@@ -121,11 +223,19 @@ async def api_run(
121
  if not yolo_ckpt:
122
  return JSONResponse({"ok": False, "error": f"Unknown od_model size: {od_model}"}, status_code=400)
123
 
124
- # save upload
125
- suffix = Path(upload.filename).suffix or ".jpg"
126
  job_id = uuid.uuid4().hex
 
127
  upload_path = UPLOAD_DIR / f"{job_id}{suffix}"
128
- upload_path.write_bytes(await upload.read())
 
 
 
 
 
 
 
 
129
 
130
  # run
131
  try:
@@ -141,13 +251,9 @@ async def api_run(
141
  viz_mode=viz_mode,
142
  )
143
  except Exception as e:
144
- # return JSONResponse({"ok": False, "error": repr(e)}, status_code=500)
145
  tb = traceback.format_exc()
146
- print(tb) # shows in HF logs
147
- return JSONResponse(
148
- {"ok": False, "error": str(e), "traceback": tb},
149
- status_code=500
150
- )
151
 
152
  # save results
153
  job_dir = RESULT_DIR / job_id
@@ -161,6 +267,7 @@ async def api_run(
161
  out["images"]["yolo"].save(p_yolo, quality=95)
162
  out["images"]["selected"].save(p_sel, quality=95)
163
 
 
164
  return {
165
  "ok": True,
166
  "job_id": job_id,
@@ -168,8 +275,8 @@ async def api_run(
168
  "task_name": out["task_name"],
169
  "selected_indices": out["selected_indices"],
170
  "image_urls": {
171
- "input": f"/results/{job_id}/input.jpg",
172
- "yolo": f"/results/{job_id}/yolo.jpg",
173
- "selected": f"/results/{job_id}/selected.jpg",
174
  },
175
- }
 
1
  import os
2
  import uuid
3
+ import io
4
+ import traceback
5
  from pathlib import Path
6
 
7
+ import numpy as np
8
+ import torch
9
+ from PIL import Image, ImageFilter
10
+
11
  from fastapi import FastAPI, Request, UploadFile, File, Form
12
  from fastapi.responses import HTMLResponse, JSONResponse
13
  from fastapi.staticfiles import StaticFiles
 
64
  DEFAULT_SAM_CKPT = str(CKPT_DIR / "sam2.1_l.pt")
65
  DEFAULT_IMAGEBIND_CKPT = str(CKPT_DIR / "imagebind_huge.pth") # optional but recommended
66
 
67
+
68
+ def _clamp_int(x, lo=0, hi=100) -> int:
69
+ try:
70
+ v = int(x)
71
+ except Exception:
72
+ v = 0
73
+ return max(lo, min(hi, v))
74
+
75
+
76
+ def apply_noise_pil(img: Image.Image, noise_type: str, strength_0_100: int) -> Image.Image:
77
+ """
78
+ Simple input-noise layer applied before running YOLO/TaskCLIP.
79
+ strength_0_100: 0..100
80
+ """
81
+ strength = _clamp_int(strength_0_100, 0, 100)
82
+ t = (noise_type or "none").lower()
83
+
84
+ if strength == 0 or t in ["none", "default", "off"]:
85
+ return img
86
+
87
+ arr = np.asarray(img).astype(np.float32)
88
+
89
+ if t == "gaussian":
90
+ # sigma in [0, 25] roughly
91
+ sigma = (strength / 100.0) * 25.0
92
+ noise = np.random.normal(0.0, sigma, size=arr.shape).astype(np.float32)
93
+ out = np.clip(arr + noise, 0, 255).astype(np.uint8)
94
+ return Image.fromarray(out)
95
+
96
+ if t == "linear":
97
+ # simple brightness/contrast-like linear shift
98
+ alpha = 1.0 + (strength / 100.0) * 0.6 # 1.0 -> 1.6
99
+ beta = (strength / 100.0) * 20.0 # 0 -> 20
100
+ out = np.clip(arr * alpha + beta, 0, 255).astype(np.uint8)
101
+ return Image.fromarray(out)
102
+
103
+ # adversarial-ish synthetic corruptions (fast, deterministic-ish)
104
+ if t in ["adv", "adv_rand_sign"]:
105
+ amp = (strength / 100.0) * 18.0
106
+ sign = np.random.choice([-1.0, 1.0], size=arr.shape).astype(np.float32)
107
+ out = np.clip(arr + sign * amp, 0, 255).astype(np.uint8)
108
+ return Image.fromarray(out)
109
+
110
+ if t == "adv_edge_sign":
111
+ # edge sign from Laplacian filter, then apply sign perturbation
112
+ gray = img.convert("L").filter(ImageFilter.FIND_EDGES)
113
+ g = np.asarray(gray).astype(np.float32) / 255.0
114
+ sign2d = np.where(g > 0.2, 1.0, -1.0).astype(np.float32) # crude edge mask
115
+ amp = (strength / 100.0) * 18.0
116
+ sign = np.repeat(sign2d[..., None], 3, axis=2)
117
+ out = np.clip(arr + sign * amp, 0, 255).astype(np.uint8)
118
+ return Image.fromarray(out)
119
+
120
+ if t == "adv_patch":
121
+ # random square occlusion / noise patch
122
+ out = arr.copy()
123
+ w, h = img.size
124
+ s = int(min(w, h) * (0.10 + 0.30 * (strength / 100.0))) # 10% -> 40%
125
+ x0 = np.random.randint(0, max(1, w - s))
126
+ y0 = np.random.randint(0, max(1, h - s))
127
+ patch = np.random.uniform(0, 255, size=(s, s, 3)).astype(np.float32)
128
+ out[y0:y0 + s, x0:x0 + s, :] = patch
129
+ return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
130
+
131
+ if t == "adv_stripes":
132
+ out = arr.copy()
133
+ h, w = out.shape[0], out.shape[1]
134
+ period = max(4, int(40 - 30 * (strength / 100.0))) # 40 -> 10
135
+ amp = (strength / 100.0) * 35.0
136
+ for x in range(0, w, period):
137
+ out[:, x:x+2, :] = np.clip(out[:, x:x+2, :] + amp, 0, 255)
138
+ return Image.fromarray(out.astype(np.uint8))
139
+
140
+ if t == "adv_jpeg":
141
+ # JPEG compression artifacts
142
+ quality = int(95 - (strength / 100.0) * 75) # 95 -> 20
143
+ quality = max(10, min(95, quality))
144
+ buf = io.BytesIO()
145
+ img.save(buf, format="JPEG", quality=quality)
146
+ buf.seek(0)
147
+ return Image.open(buf).convert("RGB")
148
+
149
+ # fallback: no-op
150
+ return img
151
+
152
+
153
  # ---- Load runner ONCE at startup ----
154
+ device_env = os.getenv("DEVICE", "").strip()
155
+ if device_env:
156
+ device = device_env
157
+ else:
158
+ device = "cuda" if torch.cuda.is_available() else "cpu"
159
+
160
  runner = ModelRunner(
161
  project_root=str(PROJECT_ROOT),
162
+ device=device,
163
  yolo_ckpt=OD_VALUE_TO_CKPT[DEFAULT_OD],
164
  sam_ckpt=DEFAULT_SAM_CKPT,
165
+ imagebind_ckpt=DEFAULT_IMAGEBIND_CKPT,
166
  id2task_name_file="./id2task_name.json",
167
  task2prompt_file="./task20.json",
168
  threshold=0.01,
 
171
  forward_thre=0.1,
172
  )
173
 
174
+
175
  @app.get("/", response_class=HTMLResponse)
176
  def index(request: Request):
177
  return templates.TemplateResponse(
 
190
  },
191
  )
192
 
193
+
194
  @app.post("/api/run")
195
  async def api_run(
196
+ request: Request,
197
  vlm_model: str = Form(DEFAULT_VLM),
198
  od_model: str = Form(DEFAULT_OD),
199
  task_id: int = Form(1),
200
  score_function: str = Form(DEFAULT_SCORE_FUNC),
201
  hdv_dim: int = Form(DEFAULT_HDV),
202
  viz_mode: str = Form("bbox"),
203
+ noise_type: str = Form("none"),
204
+ noise_strength: int = Form(0),
205
  upload: UploadFile = File(...),
206
  ):
207
  # validate + pick decoder
 
223
  if not yolo_ckpt:
224
  return JSONResponse({"ok": False, "error": f"Unknown od_model size: {od_model}"}, status_code=400)
225
 
226
+ # save upload (apply noise first)
 
227
  job_id = uuid.uuid4().hex
228
+ suffix = Path(upload.filename).suffix or ".jpg"
229
  upload_path = UPLOAD_DIR / f"{job_id}{suffix}"
230
+
231
+ raw = await upload.read()
232
+ try:
233
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
234
+ except Exception:
235
+ return JSONResponse({"ok": False, "error": "Failed to decode image upload"}, status_code=400)
236
+
237
+ img = apply_noise_pil(img, noise_type=noise_type, strength_0_100=noise_strength)
238
+ img.save(upload_path, quality=95)
239
 
240
  # run
241
  try:
 
251
  viz_mode=viz_mode,
252
  )
253
  except Exception as e:
 
254
  tb = traceback.format_exc()
255
+ print(tb)
256
+ return JSONResponse({"ok": False, "error": str(e), "traceback": tb}, status_code=500)
 
 
 
257
 
258
  # save results
259
  job_dir = RESULT_DIR / job_id
 
267
  out["images"]["yolo"].save(p_yolo, quality=95)
268
  out["images"]["selected"].save(p_sel, quality=95)
269
 
270
+ base = str(request.base_url).rstrip("/")
271
  return {
272
  "ok": True,
273
  "job_id": job_id,
 
275
  "task_name": out["task_name"],
276
  "selected_indices": out["selected_indices"],
277
  "image_urls": {
278
+ "input": f"{base}/results/{job_id}/input.jpg",
279
+ "yolo": f"{base}/results/{job_id}/yolo.jpg",
280
+ "selected": f"{base}/results/{job_id}/selected.jpg",
281
  },
282
+ }