Team Ai
Apppublic

Kamlesh21/preprocessing-lab

sourceHugging Faceupdated 11d agoView on Hugging Face
0likes
image_lab.ipynb799 linesDownload Raw Back to root
1{
2 "cells": [
3  {
4   "cell_type": "markdown",
5   "id": "5642696a",
6   "metadata": {},
7   "source": [
8    "# Image Preprocessing Lab\n",
9    "\n",
10    "Everything the demo does lives in this notebook: every preprocessing step, the two preset lines, and the two models.\n",
11    "It is plain Python: functions and dictionaries, no classes.\n",
12    "\n",
13    "The lab tells two stories about the same idea, **a model only works on the input it was trained on**:\n",
14    "\n",
15    "1. **Classify** a pet photo with ResNet50.\n",
16    "2. **Segment and count** objects with YOLO11s-seg.\n",
17    "\n",
18    "Each line has three kinds of steps:\n",
19    "\n",
20    "- **Preparation** steps turn a photo into exactly what the model expects. They are on.\n",
21    "- **Break it** steps copy what the real world does to photos: tilted cameras, dark rooms, blur, sensor noise,\n",
22    "  low resolution. They are off. Switch one on and watch the model's answer change.\n",
23    "- **Fix it** steps are classic enhancement and restoration: straighten, brighten, equalize, denoise, sharpen.\n",
24    "  They are off too. Break the photo, switch on a fix, and see how much of the answer comes back (often only part).\n",
25    "\n",
26    "**The web app loads this same notebook.** `server.py` runs every cell *except* the ones tagged `demo` (at the end),\n",
27    "so a function you change here changes what the app runs and shows.\n",
28    "\n",
29    "Conventions: images are NumPy arrays. Color images are `(height, width, 3)` in **RGB** order and `uint8` (0..255),\n",
30    "except straight after `cv2.imread`, which gives **BGR**."
31   ]
32  },
33  {
34   "cell_type": "code",
35   "execution_count": null,
36   "id": "8d2c5fe2",
37   "metadata": {},
38   "outputs": [],
39   "source": [
40    "import cv2\n",
41    "import numpy as np\n",
42    "\n",
43    "# Every step the app can use: step id -> dict(name, kind, fn, params, summary, explain).\n",
44    "STEPS = {}\n",
45    "\n",
46    "KINDS = {\n",
47    "    \"prep\": \"Preparation\",  # what the model needs\n",
48    "    \"break\": \"Break it\",    # what the real world does to photos\n",
49    "    \"fix\": \"Fix it\",        # enhancement and restoration that try to undo the damage\n",
50    "}\n",
51    "\n",
52    "\n",
53    "def slider(name, default, lo, hi, step=1, label=None, odd=False):\n",
54    "    is_int = all(isinstance(v, int) for v in (default, lo, hi, step))\n",
55    "    return dict(name=name, type=\"int\" if is_int else \"float\", default=default,\n",
56    "                min=lo, max=hi, step=step, label=label or name, odd=odd)\n",
57    "\n",
58    "\n",
59    "def add_step(fn, name, kind, summary, explain, params=(), short=None, helps=()):\n",
60    "    # short: a label that fits on a step pill (defaults to name).\n",
61    "    # helps: for a fix step, the break-it steps it counteracts.\n",
62    "    STEPS[fn.__name__] = dict(id=fn.__name__, name=name, short=short or name, kind=kind, fn=fn,\n",
63    "                              params=list(params), summary=summary, explain=explain, helps=list(helps))"
64   ]
65  },
66  {
67   "cell_type": "markdown",
68   "id": "0c869e68",
69   "metadata": {},
70   "source": [
71    "## Load an image"
72   ]
73  },
74  {
75   "cell_type": "code",
76   "execution_count": null,
77   "id": "c24212e0",
78   "metadata": {},
79   "outputs": [],
80   "source": [
81    "def load_image(path, max_side=640):\n",
82    "    img = cv2.imread(str(path))  # OpenCV loads 3 channels in B, G, R order, dtype uint8\n",
83    "    h, w = img.shape[:2]\n",
84    "    s = max_side / max(h, w)\n",
85    "    if s < 1:  # keep the demo fast: shrink big photos, never enlarge small ones\n",
86    "        img = cv2.resize(img, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)\n",
87    "    return img"
88   ]
89  },
90  {
91   "cell_type": "markdown",
92   "id": "bae4c60e",
93   "metadata": {},
94   "source": [
95    "## Preparation: what the model needs\n",
96    "\n",
97    "A trained network expects one exact kind of input: a fixed color order, a fixed size, a fixed value range and a\n",
98    "fixed memory layout. These steps get a photo there."
99   ]
100  },
101  {
102   "cell_type": "code",
103   "execution_count": null,
104   "id": "77fd08a3",
105   "metadata": {},
106   "outputs": [],
107   "source": [
108    "def bgr_to_rgb(img):\n",
109    "    return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n",
110    "\n",
111    "add_step(bgr_to_rgb, \"BGR to RGB\", \"prep\", short=\"BGR → RGB\",\n",
112    "    summary=\"Swap OpenCV's blue-first channel order to red-first.\",\n",
113    "    explain=\"cv2.imread returns pixels as Blue, Green, Red. The models were trained on Red, Green, Blue. \"\n",
114    "            \"Switch this off and every red turns blue: the model still answers, just less sure, \"\n",
115    "            \"and nothing crashes. A classic silent bug.\")"
116   ]
117  },
118  {
119   "cell_type": "code",
120   "execution_count": null,
121   "id": "d0313d1b",
122   "metadata": {},
123   "outputs": [],
124   "source": [
125    "def resize_shorter(img, size=256):\n",
126    "    h, w = img.shape[:2]\n",
127    "    s = size / min(h, w)\n",
128    "    return cv2.resize(img, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)\n",
129    "\n",
130    "add_step(resize_shorter, \"Resize\", \"prep\",\n",
131    "    summary=\"Scale the shorter side to 256 pixels, keeping the shape.\",\n",
132    "    explain=\"ResNet50 was trained on 224 by 224 images. First the shorter side is scaled to 256 so nothing gets \"\n",
133    "            \"squashed, then the next step cuts out the middle.\",\n",
134    "    params=[slider(\"size\", 256, 128, 512, 8)])"
135   ]
136  },
137  {
138   "cell_type": "code",
139   "execution_count": null,
140   "id": "dba55c8a",
141   "metadata": {},
142   "outputs": [],
143   "source": [
144    "def center_crop(img, size=224):\n",
145    "    h, w = img.shape[:2]\n",
146    "    top, left = max(0, (h - size) // 2), max(0, (w - size) // 2)\n",
147    "    return img[top:top + size, left:left + size]\n",
148    "\n",
149    "add_step(center_crop, \"Center crop\", \"prep\", short=\"Crop\",\n",
150    "    summary=\"Cut a 224 by 224 square from the middle.\",\n",
151    "    explain=\"Cropping is plain array slicing: rows first, then columns. Anything near the border is thrown \"\n",
152    "            \"away, so an animal that is off to the side can be cut out here.\",\n",
153    "    params=[slider(\"size\", 224, 64, 512, 8)])"
154   ]
155  },
156  {
157   "cell_type": "code",
158   "execution_count": null,
159   "id": "e070fbbc",
160   "metadata": {},
161   "outputs": [],
162   "source": [
163    "def letterbox(img, size=640):\n",
164    "    h, w = img.shape[:2]\n",
165    "    s = size / max(h, w)\n",
166    "    nh, nw = round(h * s), round(w * s)\n",
167    "    resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)\n",
168    "    top, left = (size - nh) // 2, (size - nw) // 2\n",
169    "    return cv2.copyMakeBorder(resized, top, size - nh - top, left, size - nw - left,\n",
170    "                              cv2.BORDER_CONSTANT, value=(114, 114, 114))\n",
171    "\n",
172    "add_step(letterbox, \"Letterbox\", \"prep\",\n",
173    "    summary=\"Fit the photo into a 640 by 640 square, padding with grey.\",\n",
174    "    explain=\"YOLO takes a square image but must not stretch objects. Letterboxing scales the long side to 640, \"\n",
175    "            \"then fills the rest with grey (114). Stretching instead would turn round objects into ovals.\",\n",
176    "    params=[slider(\"size\", 640, 320, 1024, 32)])"
177   ]
178  },
179  {
180   "cell_type": "code",
181   "execution_count": null,
182   "id": "aa24a1f5",
183   "metadata": {},
184   "outputs": [],
185   "source": [
186    "def normalize(img):\n",
187    "    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)\n",
188    "    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)\n",
189    "    return (img.astype(np.float32) / 255.0 - mean) / std\n",
190    "\n",
191    "add_step(normalize, \"Normalize\", \"prep\",\n",
192    "    summary=\"Turn 0..255 pixel values into small numbers around 0.\",\n",
193    "    explain=\"Divide by 255 to get 0..1, then subtract the ImageNet mean and divide by its standard deviation, \"\n",
194    "            \"per channel. The model was trained on values roughly between -2 and 2. Switch this off and it \"\n",
195    "            \"receives values up to 255: its answer becomes nonsense.\")"
196   ]
197  },
198  {
199   "cell_type": "code",
200   "execution_count": null,
201   "id": "6ec55aa2",
202   "metadata": {},
203   "outputs": [],
204   "source": [
205    "def to_chw(img):\n",
206    "    return np.ascontiguousarray(img.transpose(2, 0, 1))\n",
207    "\n",
208    "add_step(to_chw, \"Channels first\", \"prep\", short=\"HWC → CHW\",\n",
209    "    summary=\"Reorder the array from height, width, channels to channels, height, width.\",\n",
210    "    explain=\"Images are stored as (224, 224, 3). PyTorch wants (3, 224, 224). No pixel changes, only the order \"\n",
211    "            \"of the axes in memory.\")"
212   ]
213  },
214  {
215   "cell_type": "markdown",
216   "id": "51221b67",
217   "metadata": {},
218   "source": [
219    "## Break it: what the real world does to photos\n",
220    "\n",
221    "Each of these copies a problem real photos have. They are switched off in the presets."
222   ]
223  },
224  {
225   "cell_type": "code",
226   "execution_count": null,
227   "id": "c1aa6046",
228   "metadata": {},
229   "outputs": [],
230   "source": [
231    "def rotate(img, angle=45):\n",
232    "    h, w = img.shape[:2]\n",
233    "    M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)\n",
234    "    return cv2.warpAffine(img, M, (w, h))\n",
235    "\n",
236    "add_step(rotate, \"Rotate\", \"break\",\n",
237    "    summary=\"Tilt the photo, like a phone held at an angle.\",\n",
238    "    explain=\"Most training photos are upright, so the model has rarely seen a tilted dog. The corners that \"\n",
239    "            \"rotate out of frame are filled with black, which the model can mistake for content.\",\n",
240    "    params=[slider(\"angle\", 45, -180, 180, 5, \"angle (degrees)\")])"
241   ]
242  },
243  {
244   "cell_type": "code",
245   "execution_count": null,
246   "id": "82791573",
247   "metadata": {},
248   "outputs": [],
249   "source": [
250    "def darken(img, contrast=0.05, brightness=0):\n",
251    "    return cv2.convertScaleAbs(img, alpha=contrast, beta=brightness)\n",
252    "\n",
253    "add_step(darken, \"Dark photo\", \"break\", short=\"Dark\",\n",
254    "    summary=\"Make the photo dim and flat, like a shot in a dark room.\",\n",
255    "    explain=\"Every pixel is multiplied by the contrast and shifted by the brightness, then clipped to 0..255. \"\n",
256    "            \"At the default the whole photo is squeezed into the 14 darkest values (0 to 13). Nothing is clipped, \"\n",
257    "            \"so a fix can stretch it out again, but only those 14 shades survive, so the fixed photo looks \"\n",
258    "            \"posterized. Pull the brightness below 0 and the shadows clip to pure black: that detail is gone \"\n",
259    "            \"for good.\",\n",
260    "    params=[slider(\"contrast\", 0.05, 0.01, 2.0, 0.01), slider(\"brightness\", 0, -128, 128, 1)])"
261   ]
262  },
263  {
264   "cell_type": "code",
265   "execution_count": null,
266   "id": "52cf2bc0",
267   "metadata": {},
268   "outputs": [],
269   "source": [
270    "def blur(img, ksize=21):\n",
271    "    return cv2.GaussianBlur(img, (ksize, ksize), 0)\n",
272    "\n",
273    "add_step(blur, \"Blur\", \"break\",\n",
274    "    summary=\"Blur the photo, like a camera out of focus.\",\n",
275    "    explain=\"Each pixel becomes a weighted average of its neighbours. Fur, edges and texture, which the model \"\n",
276    "            \"relies on, fade away. The kernel size must be odd so it has a centre pixel.\",\n",
277    "    params=[slider(\"ksize\", 21, 3, 61, 2, \"kernel size\", odd=True)])"
278   ]
279  },
280  {
281   "cell_type": "code",
282   "execution_count": null,
283   "id": "ad4ad32a",
284   "metadata": {},
285   "outputs": [],
286   "source": [
287    "def add_noise(img, sigma=50):\n",
288    "    rng = np.random.default_rng(0)  # fixed seed: the same noise on every run\n",
289    "    noisy = img.astype(np.float32) + rng.normal(0, sigma, img.shape)\n",
290    "    return np.clip(noisy, 0, 255).astype(np.uint8)\n",
291    "\n",
292    "add_step(add_noise, \"Noise\", \"break\",\n",
293    "    summary=\"Add grain, like a cheap sensor at night.\",\n",
294    "    explain=\"Random values are added to every pixel. People see straight through the grain; models \"\n",
295    "            \"often do not.\",\n",
296    "    params=[slider(\"sigma\", 50, 5, 120, 5, \"noise strength\")])"
297   ]
298  },
299  {
300   "cell_type": "code",
301   "execution_count": null,
302   "id": "2192dff9",
303   "metadata": {},
304   "outputs": [],
305   "source": [
306    "def salt_pepper(img, amount=0.1):\n",
307    "    rng = np.random.default_rng(0)  # fixed seed: the same specks on every run\n",
308    "    hit = rng.random(img.shape[:2])  # one random number per pixel\n",
309    "    out = img.copy()\n",
310    "    out[hit < amount / 2] = 0        # pepper: pure black\n",
311    "    out[hit > 1 - amount / 2] = 255  # salt: pure white\n",
312    "    return out\n",
313    "\n",
314    "add_step(salt_pepper, \"Salt & pepper noise\", \"break\", short=\"Salt & pepper\",\n",
315    "    summary=\"Scatter pure black and white specks, like dust or dead pixels.\",\n",
316    "    explain=\"A fraction of the pixels (the amount) is replaced by pure black or pure white; the rest are untouched. \"\n",
317    "            \"Unlike grain, each speck is completely wrong, but the pixels around it are still right.\",\n",
318    "    params=[slider(\"amount\", 0.1, 0.01, 0.5, 0.01, \"fraction of pixels\")])"
319   ]
320  },
321  {
322   "cell_type": "code",
323   "execution_count": null,
324   "id": "e11748f8",
325   "metadata": {},
326   "outputs": [],
327   "source": [
328    "def low_resolution(img, factor=8):\n",
329    "    h, w = img.shape[:2]\n",
330    "    small = cv2.resize(img, (max(1, w // factor), max(1, h // factor)), interpolation=cv2.INTER_AREA)\n",
331    "    return cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)\n",
332    "\n",
333    "add_step(low_resolution, \"Low resolution\", \"break\", short=\"Low-res\",\n",
334    "    summary=\"Throw away detail, like a tiny thumbnail blown back up.\",\n",
335    "    explain=\"The photo is shrunk by the factor, then scaled back to its size. The image is the same size, \"\n",
336    "            \"but most of the information is gone and the edges turn into blocks.\",\n",
337    "    params=[slider(\"factor\", 8, 2, 32, 1)])"
338   ]
339  },
340  {
341   "cell_type": "markdown",
342   "id": "4978f341",
343   "metadata": {},
344   "source": [
345    "## Fix it: enhancement and restoration\n",
346    "\n",
347    "Classic image processing can undo some damage before the model sees the photo. **Enhancement** makes a photo easier\n",
348    "to read (brighter, more contrast, sharper edges). **Restoration** tries to remove a known kind of damage (tilt,\n",
349    "noise). They are off in the presets. Break the photo, switch a fix on, and watch how much of the answer comes back.\n",
350    "\n",
351    "A fix can only use what is left in the pixels. There is no fix for low resolution on purpose: once detail is thrown\n",
352    "away, no filter can bring it back."
353   ]
354  },
355  {
356   "cell_type": "code",
357   "execution_count": null,
358   "id": "a7ae4b42",
359   "metadata": {},
360   "outputs": [],
361   "source": [
362    "def straighten(img, angle=-45):\n",
363    "    h, w = img.shape[:2]\n",
364    "    M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)\n",
365    "    return cv2.warpAffine(img, M, (w, h))\n",
366    "\n",
367    "add_step(straighten, \"Straighten\", \"fix\", helps=[\"rotate\"],\n",
368    "    summary=\"Rotate the photo back upright by a known angle.\",\n",
369    "    explain=\"The same rotation as Rotate, in the other direction: -45 exactly undoes a 45 degree tilt. The animal \"\n",
370    "            \"is upright again and the answer mostly comes back, but the corners that were cut off by the first \"\n",
371    "            \"rotation stay black, because those pixels are gone.\",\n",
372    "    params=[slider(\"angle\", -45, -180, 180, 5, \"angle (degrees)\")])"
373   ]
374  },
375  {
376   "cell_type": "code",
377   "execution_count": null,
378   "id": "3cdff0c6",
379   "metadata": {},
380   "outputs": [],
381   "source": [
382    "def gamma(img, gamma=0.5):\n",
383    "    table = (255 * (np.arange(256) / 255) ** gamma).astype(np.uint8)  # new value for each old value\n",
384    "    return cv2.LUT(img, table)\n",
385    "\n",
386    "add_step(gamma, \"Gamma correction\", \"fix\", short=\"Gamma\", helps=[\"darken\"],\n",
387    "    summary=\"Brighten the dark tones much more than the bright ones.\",\n",
388    "    explain=\"Every value v becomes 255 * (v / 255) ** gamma. With gamma below 1, dark values are lifted a lot and \"\n",
389    "            \"bright ones barely move, so shadows open up without white areas burning out. On a dark photo it brings \"\n",
390    "            \"the answer back almost fully, as long as the shadows were not clipped to black.\",\n",
391    "    params=[slider(\"gamma\", 0.5, 0.2, 3.0, 0.05)])"
392   ]
393  },
394  {
395   "cell_type": "code",
396   "execution_count": null,
397   "id": "4e7b6f73",
398   "metadata": {},
399   "outputs": [],
400   "source": [
401    "def equalize(img):\n",
402    "    ycc = cv2.cvtColor(img, cv2.COLOR_RGB2YCrCb)  # Y = brightness, Cr and Cb = color\n",
403    "    y = cv2.equalizeHist(np.ascontiguousarray(ycc[:, :, 0]))\n",
404    "    return cv2.cvtColor(np.dstack([y, ycc[:, :, 1], ycc[:, :, 2]]), cv2.COLOR_YCrCb2RGB)\n",
405    "\n",
406    "add_step(equalize, \"Histogram equalization\", \"fix\", short=\"Equalize\", helps=[\"darken\"],\n",
407    "    summary=\"Spread the brightness values over the whole 0..255 range.\",\n",
408    "    explain=\"Each brightness level is moved so that the levels spread out over 0..255, in proportion to how many \"\n",
409    "            \"pixels have them; the colors are left alone. It cannot invent new levels: a dim photo with only a few \"\n",
410    "            \"dozen levels comes back with the same few dozen, spread apart (see the gaps in the histogram). \"\n",
411    "            \"Sometimes that is enough for the model, and sometimes the harsh result makes the answer worse.\")"
412   ]
413  },
414  {
415   "cell_type": "code",
416   "execution_count": null,
417   "id": "64427bd4",
418   "metadata": {},
419   "outputs": [],
420   "source": [
421    "def clahe(img, clip=4.0, tiles=8):\n",
422    "    lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)  # L = lightness, A and B = color\n",
423    "    tool = cv2.createCLAHE(clipLimit=clip, tileGridSize=(tiles, tiles))\n",
424    "    l = tool.apply(np.ascontiguousarray(lab[:, :, 0]))\n",
425    "    return cv2.cvtColor(np.dstack([l, lab[:, :, 1], lab[:, :, 2]]), cv2.COLOR_LAB2RGB)\n",
426    "\n",
427    "add_step(clahe, \"CLAHE\", \"fix\", helps=[\"darken\"],\n",
428    "    summary=\"Equalize contrast tile by tile, with a limit so noise is not blown up.\",\n",
429    "    explain=\"Contrast Limited Adaptive Histogram Equalization splits the lightness into tiles and equalizes each \"\n",
430    "            \"one, so a dark corner and a bright sky are both fixed. The clip limit caps how much contrast a tile \"\n",
431    "            \"may gain, which keeps flat areas from turning into noise. On a dark photo it helps, but a little less \"\n",
432    "            \"than plain gamma, because each tile is stretched on its own.\",\n",
433    "    params=[slider(\"clip\", 4.0, 1.0, 8.0, 0.5, \"clip limit\"), slider(\"tiles\", 8, 2, 16, 1, \"tiles per side\")])"
434   ]
435  },
436  {
437   "cell_type": "code",
438   "execution_count": null,
439   "id": "cbfa1e10",
440   "metadata": {},
441   "outputs": [],
442   "source": [
443    "def denoise(img, h=8):\n",
444    "    # Non-local means: average each pixel with pixels whose surrounding 7x7 patch looks alike,\n",
445    "    # searching a 15x15 window around it.\n",
446    "    return cv2.fastNlMeansDenoisingColored(np.ascontiguousarray(img), None, h, h, 7, 15)\n",
447    "\n",
448    "add_step(denoise, \"Non-local means denoise\", \"fix\", short=\"Denoise\", helps=[\"add_noise\"],\n",
449    "    summary=\"Remove grain by averaging patches that look alike.\",\n",
450    "    explain=\"For each pixel, similar-looking patches in a 15 by 15 neighbourhood are found and averaged, so random \"\n",
451    "            \"grain cancels out while edges survive. The photo looks much cleaner, but the model often barely \"\n",
452    "            \"changes its answer: YOLO gets a few objects back on some photos, and ResNet hardly needs it (a higher \"\n",
453    "            \"h smooths away the fur it relies on). A cleaner picture for people is not always a better one for a \"\n",
454    "            \"model. It is the slowest step in the lab.\",\n",
455    "    params=[slider(\"h\", 8, 3, 40, 1, \"strength\")])"
456   ]
457  },
458  {
459   "cell_type": "code",
460   "execution_count": null,
461   "id": "03bdfff8",
462   "metadata": {},
463   "outputs": [],
464   "source": [
465    "def median_filter(img, ksize=5):\n",
466    "    return cv2.medianBlur(np.ascontiguousarray(img), ksize)\n",
467    "\n",
468    "add_step(median_filter, \"Median filter\", \"fix\", short=\"Median\", helps=[\"salt_pepper\"],\n",
469    "    summary=\"Replace each pixel by the middle value of its neighbours.\",\n",
470    "    explain=\"Sort the pixels in a small square and keep the middle one. A black or white speck is almost never the \"\n",
471    "            \"middle value, so it simply disappears, while edges stay sharp (unless the specks cover so much of the \"\n",
472    "            \"photo that they become the middle value). Against grain it is a mixed bag: every pixel is a little \"\n",
473    "            \"wrong, so YOLO finds a few more objects, but ResNet loses the fur texture it relies on.\",\n",
474    "    params=[slider(\"ksize\", 5, 3, 15, 2, \"kernel size\", odd=True)])"
475   ]
476  },
477  {
478   "cell_type": "code",
479   "execution_count": null,
480   "id": "99202eb5",
481   "metadata": {},
482   "outputs": [],
483   "source": [
484    "def sharpen(img, amount=1.5, sigma=3.0):\n",
485    "    blurred = cv2.GaussianBlur(img, (0, 0), sigma)\n",
486    "    return cv2.addWeighted(img, 1 + amount, blurred, -amount, 0)  # img + amount * (img - blurred)\n",
487    "\n",
488    "add_step(sharpen, \"Unsharp mask\", \"fix\", short=\"Sharpen\", helps=[\"blur\"],\n",
489    "    summary=\"Boost edges by adding back the difference from a blurred copy.\",\n",
490    "    explain=\"Blur a copy, subtract it from the photo to get only the edges, and add those edges back, amplified. \"\n",
491    "            \"Edges that are still faintly there become crisp again, so the model gets part of its answer back; \"\n",
492    "            \"detail the blur fully erased cannot return, and too much sharpening adds bright halos.\",\n",
493    "    params=[slider(\"amount\", 1.5, 0.0, 5.0, 0.1), slider(\"sigma\", 3.0, 0.5, 10.0, 0.5, \"blur radius\")])"
494   ]
495  },
496  {
497   "cell_type": "markdown",
498   "id": "fa672082",
499   "metadata": {},
500   "source": [
501    "## Running a pipeline\n",
502    "\n",
503    "A pipeline is a list of steps: `{\"op\": step id, \"params\": {...}, \"enabled\": True}`. `run_step` runs one;\n",
504    "`run_pipeline` runs them all and keeps every intermediate image."
505   ]
506  },
507  {
508   "cell_type": "code",
509   "execution_count": null,
510   "id": "e2d1e672",
511   "metadata": {},
512   "outputs": [],
513   "source": [
514    "def clean_params(step_id, params=None):\n",
515    "    # Fill in defaults and keep every value inside its slider range (kernel sizes stay odd).\n",
516    "    params = params or {}\n",
517    "    out = {}\n",
518    "    for p in STEPS[step_id][\"params\"]:\n",
519    "        v = params.get(p[\"name\"], p[\"default\"])\n",
520    "        if p[\"type\"] == \"int\":\n",
521    "            v = min(p[\"max\"], max(p[\"min\"], int(round(float(v)))))\n",
522    "            if p.get(\"odd\") and v % 2 == 0:\n",
523    "                v += 1\n",
524    "        else:\n",
525    "            v = min(p[\"max\"], max(p[\"min\"], float(v)))\n",
526    "        out[p[\"name\"]] = v\n",
527    "    return out\n",
528    "\n",
529    "\n",
530    "def run_step(img, step_id, params):\n",
531    "    return STEPS[step_id][\"fn\"](img, **params)\n",
532    "\n",
533    "\n",
534    "def run_pipeline(img, steps):\n",
535    "    # img: the BGR image from load_image. Returns one (name, image) per step, starting with the input.\n",
536    "    results = [(\"Load image\", img)]\n",
537    "    for step in steps:\n",
538    "        if not step.get(\"enabled\", True):\n",
539    "            continue\n",
540    "        img = run_step(img, step[\"op\"], clean_params(step[\"op\"], step.get(\"params\")))\n",
541    "        results.append((STEPS[step[\"op\"]][\"name\"], img))\n",
542    "    return results"
543   ]
544  },
545  {
546   "cell_type": "markdown",
547   "id": "8d258447",
548   "metadata": {},
549   "source": [
550    "## Models\n",
551    "\n",
552    "**ResNet50** (ImageNet, 1000 classes) classifies. **YOLO11s-seg** (COCO, 80 classes) finds, outlines and names every\n",
553    "object. Weights are read from `weights/`, downloaded once by `scripts/fetch_models.py`."
554   ]
555  },
556  {
557   "cell_type": "code",
558   "execution_count": null,
559   "id": "9b1768a7",
560   "metadata": {},
561   "outputs": [],
562   "source": [
563    "import os\n",
564    "from pathlib import Path\n",
565    "\n",
566    "HERE = Path(globals().get(\"__file__\", \"image_lab.ipynb\")).resolve().parent  # the backend folder\n",
567    "WEIGHTS = HERE / \"weights\"\n",
568    "os.environ.setdefault(\"TORCH_HOME\", str(WEIGHTS / \"torch\"))\n",
569    "os.environ.setdefault(\"YOLO_OFFLINE\", \"1\")\n",
570    "\n",
571    "import torch\n",
572    "from torchvision.models import ResNet50_Weights, resnet50\n",
573    "\n",
574    "LABELS = ResNet50_Weights.IMAGENET1K_V1.meta[\"categories\"]  # the 1000 ImageNet class names\n",
575    "\n",
576    "\n",
577    "def load_resnet50():\n",
578    "    return resnet50(weights=ResNet50_Weights.IMAGENET1K_V1).eval()\n",
579    "\n",
580    "\n",
581    "def classify_resnet50(img, model):\n",
582    "    # img: the pipeline's output, which should be float32 (3, 224, 224). It goes in exactly as it is, so a\n",
583    "    # switched-off step really reaches the model. Returns the probability of every class.\n",
584    "    x = torch.from_numpy(np.ascontiguousarray(img, dtype=np.float32))[None]  # add a batch axis\n",
585    "    with torch.no_grad():\n",
586    "        logits = model(x)\n",
587    "    return logits.softmax(dim=1)[0].numpy()\n",
588    "\n",
589    "\n",
590    "def top_k(probs, k=5):\n",
591    "    best = np.argsort(probs)[::-1][:k]\n",
592    "    return [(LABELS[i], round(float(probs[i]), 4)) for i in best]"
593   ]
594  },
595  {
596   "cell_type": "code",
597   "execution_count": null,
598   "id": "f78c18c4",
599   "metadata": {},
600   "outputs": [],
601   "source": [
602    "def as_uint8_rgb(img):\n",
603    "    # Undo float, CHW or gray layouts so any pipeline output can be shown or fed to YOLO.\n",
604    "    if img.ndim == 3 and img.shape[0] == 3 and img.shape[2] != 3:\n",
605    "        img = img.transpose(1, 2, 0)\n",
606    "    if img.dtype != np.uint8:\n",
607    "        img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)\n",
608    "    if img.ndim == 2:\n",
609    "        img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)\n",
610    "    return np.ascontiguousarray(img)"
611   ]
612  },
613  {
614   "cell_type": "code",
615   "execution_count": null,
616   "id": "a84cb348",
617   "metadata": {},
618   "outputs": [],
619   "source": [
620    "from ultralytics import YOLO\n",
621    "\n",
622    "\n",
623    "def load_yolo():\n",
624    "    return YOLO(str(WEIGHTS / \"yolo11s-seg.pt\"))\n",
625    "\n",
626    "\n",
627    "def class_color(class_id):\n",
628    "    # One fixed color per COCO class, identical on every screen.\n",
629    "    hue = int(class_id * 0.618 % 1 * 180)\n",
630    "    rgb = cv2.cvtColor(np.uint8([[[hue, 200, 220]]]), cv2.COLOR_HSV2RGB)[0, 0]\n",
631    "    return tuple(int(c) for c in rgb)\n",
632    "\n",
633    "\n",
634    "def draw_label(img, text, x, y, color):\n",
635    "    font, scale, thick = cv2.FONT_HERSHEY_SIMPLEX, max(0.5, img.shape[1] / 1000), 1\n",
636    "    (tw, th), base = cv2.getTextSize(text, font, scale, thick)\n",
637    "    x, y = min(max(0, x), img.shape[1] - tw - 6), max(th + base + 4, y)\n",
638    "    cv2.rectangle(img, (x, y - th - base - 4), (x + tw + 6, y), color, -1)\n",
639    "    cv2.putText(img, text, (x + 3, y - base - 2), font, scale, (0, 0, 0), thick, cv2.LINE_AA)\n",
640    "\n",
641    "\n",
642    "def segment_yolo(img, model, conf=0.25):\n",
643    "    # img: RGB uint8 (H, W, 3). Returns an image with every object outlined and named,\n",
644    "    # and one (class, confidence) per object.\n",
645    "    result = model.predict(img[:, :, ::-1], conf=conf, verbose=False)[0]  # Ultralytics expects BGR\n",
646    "    overlay = img.copy()\n",
647    "    instances = []\n",
648    "    if result.masks is None:\n",
649    "        return overlay, instances\n",
650    "    polygons = result.masks.xy\n",
651    "    classes = [int(c) for c in result.boxes.cls.tolist()]\n",
652    "    scores = result.boxes.conf.tolist()\n",
653    "    for polygon, cls in zip(polygons, classes):  # fill every mask first, so labels stay on top\n",
654    "        layer = overlay.copy()\n",
655    "        cv2.fillPoly(layer, [polygon.astype(np.int32)], class_color(cls))\n",
656    "        overlay = cv2.addWeighted(layer, 0.45, overlay, 0.55, 0)\n",
657    "    for polygon, cls, score in zip(polygons, classes, scores):\n",
658    "        pts = polygon.astype(np.int32)\n",
659    "        if len(pts) == 0:\n",
660    "            continue\n",
661    "        cv2.polylines(overlay, [pts], True, class_color(cls), 2, cv2.LINE_AA)\n",
662    "        top = pts[pts[:, 1].argmin()]\n",
663    "        draw_label(overlay, f\"{result.names[cls]} {score:.0%}\", int(top[0]), int(top[1]), class_color(cls))\n",
664    "        instances.append((result.names[cls], round(score, 3)))\n",
665    "    return overlay, instances"
666   ]
667  },
668  {
669   "cell_type": "markdown",
670   "id": "039b1d9c",
671   "metadata": {},
672   "source": [
673    "## The two lines\n",
674    "\n",
675    "Preparation steps are on; break-it and fix-it steps are off. Switch one on (here: `\"enabled\": True`) to see what\n",
676    "breaks, then switch on a fix to see what comes back.\n",
677    "\n",
678    "`best_fix` names, for each break, the fix that brings the answer back best on the line's default photo (measured,\n",
679    "and checked by the tests). The app offers it first. A break that is missing there has no fix that reliably helps on\n",
680    "that line: for noise the photo looks cleaner after Denoise, but the answer barely moves."
681   ]
682  },
683  {
684   "cell_type": "code",
685   "execution_count": null,
686   "id": "4a3b7b7b",
687   "metadata": {},
688   "outputs": [],
689   "source": [
690    "BREAKS = [{\"op\": op, \"enabled\": False}\n",
691    "          for op in [\"rotate\", \"darken\", \"blur\", \"add_noise\", \"salt_pepper\", \"low_resolution\"]]\n",
692    "FIXES = [{\"op\": op, \"enabled\": False}\n",
693    "         for op in [\"straighten\", \"gamma\", \"equalize\", \"clahe\", \"denoise\", \"median_filter\", \"sharpen\"]]\n",
694    "\n",
695    "LINES = [\n",
696    "    dict(id=\"classify\", name=\"Classify\", model=\"ResNet50\", default_image=\"pet-boxer\",\n",
697    "         question=\"What animal is this?\",\n",
698    "         task={\"type\": \"classify\"},\n",
699    "         best_fix={\"rotate\": \"straighten\", \"darken\": \"gamma\", \"salt_pepper\": \"median_filter\"},\n",
700    "         steps=[{\"op\": \"bgr_to_rgb\"}, *BREAKS, *FIXES,\n",
701    "                {\"op\": \"resize_shorter\"}, {\"op\": \"center_crop\"}, {\"op\": \"normalize\"}, {\"op\": \"to_chw\"}]),\n",
702    "    dict(id=\"segment\", name=\"Segment\", model=\"YOLO11s-seg\", default_image=\"coco-000000000049\",\n",
703    "         question=\"What objects are here, and how many?\",\n",
704    "         task={\"type\": \"segment\", \"conf\": 0.25},\n",
705    "         best_fix={\"rotate\": \"straighten\", \"darken\": \"gamma\", \"blur\": \"sharpen\", \"salt_pepper\": \"median_filter\"},\n",
706    "         steps=[{\"op\": \"bgr_to_rgb\"}, *BREAKS, *FIXES, {\"op\": \"letterbox\"}]),\n",
707    "]"
708   ]
709  },
710  {
711   "cell_type": "markdown",
712   "id": "a17e515d",
713   "metadata": {},
714   "source": [
715    "## Try it\n",
716    "\n",
717    "These cells are tagged `demo`: they run here in the notebook, never in the web app."
718   ]
719  },
720  {
721   "cell_type": "code",
722   "execution_count": null,
723   "id": "5675e5ee",
724   "metadata": {
725    "tags": [
726     "demo"
727    ]
728   },
729   "outputs": [],
730   "source": [
731    "import matplotlib.pyplot as plt\n",
732    "\n",
733    "\n",
734    "def show_steps(results, cols=4):\n",
735    "    rows = (len(results) + cols - 1) // cols\n",
736    "    fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 4 * rows))\n",
737    "    for ax in np.ravel(axes):\n",
738    "        ax.axis(\"off\")\n",
739    "    for ax, (name, img) in zip(np.ravel(axes), results):\n",
740    "        ax.imshow(as_uint8_rgb(img))\n",
741    "        ax.set_title(f\"{name}\\n{img.shape} {img.dtype}\", fontsize=9)\n",
742    "    plt.tight_layout()\n",
743    "    plt.show()\n",
744    "\n",
745    "\n",
746    "results = run_pipeline(load_image(\"data/pets/boxer.jpg\"), LINES[0][\"steps\"])\n",
747    "show_steps(results)"
748   ]
749  },
750  {
751   "cell_type": "code",
752   "execution_count": null,
753   "id": "4cc31c8f",
754   "metadata": {
755    "tags": [
756     "demo"
757    ]
758   },
759   "outputs": [],
760   "source": [
761    "model = load_resnet50()\n",
762    "for label, prob in top_k(classify_resnet50(results[-1][1], model)):\n",
763    "    print(f\"{prob:6.1%}  {label}\")"
764   ]
765  },
766  {
767   "cell_type": "code",
768   "execution_count": null,
769   "id": "171a98ad",
770   "metadata": {
771    "tags": [
772     "demo"
773    ]
774   },
775   "outputs": [],
776   "source": [
777    "from collections import Counter\n",
778    "\n",
779    "results = run_pipeline(load_image(\"data/coco/000000000049.jpg\"), LINES[1][\"steps\"])\n",
780    "overlay, instances = segment_yolo(results[-1][1], load_yolo())\n",
781    "print(Counter(name for name, _ in instances))\n",
782    "plt.figure(figsize=(8, 8)); plt.imshow(overlay); plt.axis(\"off\"); plt.show()"
783   ]
784  }
785 ],
786 "metadata": {
787  "kernelspec": {
788   "display_name": "Python (imgdemo)",
789   "language": "python",
790   "name": "imgdemo"
791  },
792  "language_info": {
793   "name": "python"
794  }
795 },
796 "nbformat": 4,
797 "nbformat_minor": 5
798}
799