Notebook reconstruction · image analysis

Road-sign image analysis
Python worklog

This page reconstructs the Python cells used to inspect and enhance the supplied night-driving frame: Pillow crops, OpenCV local-contrast experiments, route-sign pixel analysis, and a Richardson–Lucy deconvolution sweep. Failed experiments are retained because they are part of the actual worklog.

Spontaneous solution from GPT-5.6-Sol to a question about identifying the location of a highway.

Source: 1847 × 788 PNGPillowOpenCVNumPyMatplotlibscikit-image
[1]

Load the source frame

Open the supplied frame with Pillow and verify its pixel dimensions before choosing crop coordinates.

Python
from PIL import Image
img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
img.size
Output
(1847, 788)
69b09c25-0e8d-48fb-8826-ae9405e6e49f.png
69b09c25-0e8d-48fb-8826-ae9405e6e49f.png
[2]

Initial visual crop of the sign area

Make a quick Matplotlib crop around the overhead road sign and nearby facade to inspect the highest-value clues.

Python
import matplotlib.pyplot as plt
img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
crop=img.crop((180,20,750,260))
plt.figure(figsize=(14,6)); plt.imshow(crop); plt.axis('off');
Output

This was an interactive notebook display, so no standalone image was saved at this step.

[3]

Pillow enhancement of the road sign

Upscale the sign region with Lanczos resampling, then increase contrast and sharpness to make letter shapes easier to inspect.

Python
from PIL import Image, ImageEnhance, ImageFilter
sign=img.crop((220,40,690,300))
sign_big=sign.resize((1880,1040), Image.Resampling.LANCZOS)
sign_big=ImageEnhance.Contrast(sign_big).enhance(1.5)
sign_big=ImageEnhance.Sharpness(sign_big).enhance(2)
sign_big.save('/mnt/data/sign_enh.png')
Output
sign_enh.png
sign_enh.png
[4]

OpenCV CLAHE + unsharp mask

Try a different enhancement pipeline: cubic upscale, local contrast enhancement in LAB space, then an unsharp mask.

Python
import cv2, numpy as np, matplotlib.pyplot as plt, PIL.Image as PImage
arr=cv2.imread('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
# sign region exact
r=arr[45:220, 240:680]
# upscale nearest/cubic and unsharp
up=cv2.resize(r, None, fx=5, fy=5, interpolation=cv2.INTER_CUBIC)
# CLAHE on LAB
lab=cv2.cvtColor(up,cv2.COLOR_BGR2LAB)
l,a,b=cv2.split(lab)
cl=cv2.createCLAHE(clipLimit=2.0,tileGridSize=(8,8)).apply(l)
enh=cv2.cvtColor(cv2.merge([cl,a,b]),cv2.COLOR_LAB2BGR)
gauss=cv2.GaussianBlur(enh,(0,0),2)
sharp=cv2.addWeighted(enh,1.8,gauss,-0.8,0)
cv2.imwrite('/mnt/data/sign_cv.png', sharp)
Output
True
sign_cv.png
sign_cv.png
[5]

Enhance the dealership side of the frame

Crop the right-hand roadside businesses and boost contrast/sharpness to inspect logos, signage and facade geometry.

Python
right=img.crop((960,80,1840,520))
up=right.resize((1760,880), Image.Resampling.LANCZOS)
up=ImageEnhance.Contrast(up).enhance(1.35)
up=ImageEnhance.Sharpness(up).enhance(1.7)
up.save('/mnt/data/right_enh.png')
Output
right_enh.png
right_enh.png
[6]

Inspect the tiny route-marker regions

Enlarge two small sign areas with nearest-neighbor sampling to preserve the original pixel structure instead of inventing smooth edges.

Python
import numpy as np, PIL.Image as PImage, matplotlib.pyplot as plt
im=np.array(img)
# show tiny route boxes exactly
fig,axs=plt.subplots(1,2,figsize=(8,3))
axs[0].imshow(img.crop((260,55,380,150)).resize((480,380),Image.Resampling.NEAREST)); axs[0].axis('off')
axs[1].imshow(img.crop((495,70,605,170)).resize((440,400),Image.Resampling.NEAREST)); axs[1].axis('off')
Output

Interactive comparison only; no separate file was written.

[7]

Plot a coordinate grid over the source frame

Use a zoomed coordinate grid to refine crop boundaries for the route shields and sign text.

Python
img0=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
# annotate rough grid
plt.figure(figsize=(18,8))
plt.imshow(img0)
plt.xlim(150,800); plt.ylim(320,0); plt.grid()
Output

Interactive Matplotlib output.

[8]

Compare the two route-shield crops

Make a tighter side-by-side comparison of the likely route-number panels.

Python
fig,axs=plt.subplots(1,2,figsize=(8,3))
axs[0].imshow(img0.crop((265,55,325,105)).resize((600,500),Image.Resampling.NEAREST)); axs[0].axis('off')
axs[1].imshow(img0.crop((440,55,485,108)).resize((450,530),Image.Resampling.NEAREST)); axs[1].axis('off')
Output

Interactive Matplotlib output.

[9]

Nearest-neighbor sign enlargement

Create a deliberately blocky enlargement of the original sign pixels so blur structure can be judged without interpolation artifacts.

Python
from PIL import Image, ImageEnhance, ImageFilter
img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
# crop sign more tightly original pixels
crop=img.crop((255,35,500,185))
# nearest upscale 6x
near=crop.resize((crop.width*6,crop.height*6), Image.Resampling.NEAREST)
near.save('/mnt/data/sign_nearest.png')
print(crop.size)
Output
(245, 150)
sign_nearest.png
sign_nearest.png
[10]

Check for scikit-image

Verify that scikit-image is installed before trying a deconvolution experiment.

Python
import importlib.util, sys, os, numpy as np
print("skimage", importlib.util.find_spec("skimage"))
Output
skimage ModuleSpec(name='skimage', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f5fd7da26a0>, origin='/opt/pyvenv/lib/python3.13/site-packages/skimage/__init__.py', submodule_search_locations=['/opt/pyvenv/lib/python3.13/site-packages/skimage'])
[INTERLUDE]

Analyzed dealership signage and blurred text clues

Output
[11]

Richardson–Lucy deconvolution: first attempt

Sweep several horizontal blur-kernel lengths and iteration counts. This first version failed while rescaling the deconvolved intensity range.

Python
from PIL import Image
import numpy as np, matplotlib.pyplot as plt
from skimage.restoration import richardson_lucy
from skimage import exposure

img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png').convert('RGB')
# text only
c=np.array(img.crop((325,55,455,150)).convert('L'))/255.0
results=[]
for length in [3,5,7,9,11,13]:
    psf=np.zeros((1,length))
    psf[0,:]=1/length
    for it in [5,10,20]:
        de=richardson_lucy(c, psf, num_iter=it, clip=False)
        de=exposure.rescale_intensity(de, in_range=np.percentile(de,[2,98]), out_range=(0,1))
        results.append((length,it,de))
fig,axs=plt.subplots(6,3,figsize=(12,14))
for ax,(l,it,de) in zip(axs.ravel(),results):
    ax.imshow(de,cmap='gray',vmin=0,vmax=1)
    ax.set_title(f"L{l} it{it}")
    ax.axis('off')
plt.tight_layout()
plt.savefig('/mnt/data/deconv_contact.png',dpi=200)
Output
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
[12]

Richardson–Lucy deconvolution: corrected rescaling

Repeat the sweep but normalize the 2nd–98th percentile range manually, avoiding the previous API mismatch.

Python
from PIL import Image
import numpy as np, matplotlib.pyplot as plt
from skimage.restoration import richardson_lucy

img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png').convert('RGB')
c=np.array(img.crop((325,55,455,150)).convert('L'), dtype=float)/255.0
results=[]
for length in [3,5,7,9,11,13]:
    psf=np.ones((1,length), dtype=float)/length
    for it in [5,10,20]:
        de=richardson_lucy(c, psf, num_iter=it, clip=False)
        lo,hi=np.percentile(de,[2,98])
        de=np.clip((de-lo)/(hi-lo+1e-9),0,1)
        results.append((length,it,de))
fig,axs=plt.subplots(6,3,figsize=(12,14))
for ax,(l,it,de) in zip(axs.ravel(),results):
    ax.imshow(de,cmap='gray',vmin=0,vmax=1)
    ax.set_title(f"L{l} it{it}")
    ax.axis('off')
plt.tight_layout()
plt.savefig('/mnt/data/deconv_contact.png',dpi=160)
print('/mnt/data/deconv_contact.png')
Output
/mnt/data/deconv_contact.png
deconv_contact.png
deconv_contact.png
[13]

Alternative Pillow sign enhancement

Try a slightly wider crop with stronger contrast/sharpness to compare whether the text becomes more legible.

Python
from PIL import Image, ImageEnhance, ImageFilter
img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
crop=img.crop((240,30,510,200))
crop=crop.resize((1080,680))
crop=ImageEnhance.Contrast(crop).enhance(1.8)
crop=ImageEnhance.Sharpness(crop).enhance(2.5)
path='/mnt/data/sign_zoom2.png'
crop.save(path)
path
Output
'/mnt/data/sign_zoom2.png'
sign_zoom2.png
sign_zoom2.png
[14]

Coordinate-grid inspection of the sign

Convert the source to NumPy and draw a grid over the sign region to estimate precise text-line coordinates.

Python
from PIL import Image
import numpy as np, matplotlib.pyplot as plt
img=np.array(Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png').convert('RGB'))
# approximate text line bounds from original
# display sign coordinate with grid perhaps
c=img[45:170,250:500]
plt.figure(figsize=(15,6))
plt.imshow(c)
plt.xticks(np.arange(0,251,10))
plt.yticks(np.arange(0,126,10))
plt.grid()
plt.show()
Output

Interactive Matplotlib output.

[15]

Column-brightness projections for the text lines

Use the red channel as a crude white-text detector on the blue sign and plot horizontal profiles to estimate where characters begin and end.

Python
import numpy as np, matplotlib.pyplot as plt
gray=img.mean(axis=2)
# lines orig coords
top=gray[65:87,338:430] # maybe
bot=gray[94:115,338:430]
# whiteness relative to blue background: use R channel maybe text white = high R, background blue = low R
R=img[:,:,0].astype(float)
topR=R[65:90,335:435]
botR=R[94:118,335:435]
# projection positive after subtract median baseline per column? simple max or percentile
for arr,name in [(topR,'top'),(botR,'bottom')]:
    prof=np.mean(arr,axis=0)
    # smooth?
    plt.figure(figsize=(12,2))
    plt.plot(prof)
    plt.title(name)
    plt.xticks(range(0,101,5))
    plt.grid()
    plt.show
Output

Interactive plots; the final line intentionally references plt.show without calling it, matching the original cell.

[16]

Measure color dominance in sign subregions

Compare RGB statistics in the two suspected route badges and central text area to see whether any route marker carries a distinct color.

Python
from PIL import Image
import numpy as np, colorsys, pandas as pd, math
img=np.array(Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png').convert('RGB'))
# approximate sign bounding box x=262..494 y=47..169
# examine badge regions
regions={
    'left_badge': img[55:115, 270:330],
    'right_badge': img[62:120, 425:485],
    'center_text': img[55:125, 330:425],
}
for name,a in regions.items():
    # mean rgb and top chromatic pixels
    flat=a.reshape(-1,3)
    print(name, flat.mean(0), flat.min(0), flat.max(0))
    # pixel proportions where red dominance or green dominance
    r,g,b=flat[:,0],flat[:,1],flat[:,2]
    print("red dom",np.mean((r>g*1.15)&(r>b*1.15)),"green dom",np.mean((g>r*1.15)&(g>b*1.05)), "blue dom", np.mean((b>r*1.1)&(b>g*1.05)))
Output
left_badge [ 83.97083333 191.10555556 230.00055556] [ 15 168 208] [180 210 249]
red dom 0.0 green dom 0.0 blue dom 1.0
right_badge [ 40.72988506 173.38477011 215.58132184] [  0 128 182] [191 214 249]
red dom 0.0 green dom 0.0 blue dom 1.0
center_text [ 63.30962406 181.15804511 224.63954887] [  0 129 185] [184 216 255]
red dom 0.0 green dom 0.0 blue dom 1.0
[17]

Top-hat morphology on the sign

Try isolating bright lettering from the blue background with a grayscale morphological top-hat transform, then save both grayscale and top-hat variants.

Python
import cv2, numpy as np, matplotlib.pyplot as plt, os, PIL.Image as I
im=cv2.imread('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
# sign region
s=im[45:180,260:500]
# channels perhaps use min? Text white on blue, grayscale
gray=cv2.cvtColor(s,cv2.COLOR_BGR2GRAY)
# subtract local background via morphological opening?
# white features use top-hat
kernel=cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(15,15))
tophat=cv2.morphologyEx(gray,cv2.MORPH_TOPHAT,kernel)
# crop text lines
# save variants
outs=[]
for i,arr in enumerate([gray,tophat]):
    arr2=cv2.resize(arr,None,fx=6,fy=6,interpolation=cv2.INTER_CUBIC)
    if i==1:
        arr2=cv2.normalize(arr2,None,0,255,cv2.NORM_MINMAX)
    cv2.imwrite(f'/mnt/data/sign_proc{i}.png',arr2)
'/mnt/data/sign_proc1.png' 
Output
'/mnt/data/sign_proc1.png'
sign_proc0.png
sign_proc0.png
sign_proc1.png
sign_proc1.png
[18]

Extract the two destination-text lines

Save tight top/bottom line crops using both nearest-neighbor and cubic interpolation for side-by-side visual comparison.

Python
import cv2, numpy as np
im=cv2.imread('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
# let's save very tight text crop without badges
# original sign around x260-500 y45-180. text top x333-423 y56-84, bottom x336-418 y86-114
for name, y1,y2 in [('top',55,86),('bottom',83,115)]:
    a=im[y1:y2,325:430]
    # upscale nearest and cubic
    for method,label in [(cv2.INTER_NEAREST,'n'),(cv2.INTER_CUBIC,'c')]:
        b=cv2.resize(a,None,fx=12,fy=12,interpolation=method)
        cv2.imwrite(f'/mnt/data/{name}_{label}.png',b)
Output
top_n.png
top_n.png
top_c.png
top_c.png
bottom_n.png
bottom_n.png
bottom_c.png
bottom_c.png
[19]

Reload the original frame before another crop

Reinitialize the Pillow image object after NumPy/OpenCV experiments and confirm dimensions again.

Python
from PIL import Image, ImageEnhance, ImageFilter
img=Image.open('/mnt/data/69b09c25-0e8d-48fb-8826-ae9405e6e49f.png')
img.size
Output
(1847, 788)
[20]

Create a larger sign crop

Produce a wider, high-resolution sign crop with moderate enhancement for manual visual comparison.

Python
crop=img.crop((240,35,510,205)).resize((1080,680))
crop=ImageEnhance.Sharpness(crop).enhance(2)
crop=ImageEnhance.Contrast(crop).enhance(1.4)
crop.save('/mnt/data/sign_crop_big.png')
Output
sign_crop_big.png
sign_crop_big.png
[21]

Compare candidate destination-name widths

Measure rendered widths of candidate place names in the same font as a rough sanity check against the blurred text lengths.

Python
from PIL import ImageFont
font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 100)
for s in ["Rzeszów","Przemyśl","București","Ploiești","Rzeszow","Przemysl"]:
    print(s, font.getlength(s))
Output
Rzeszów 431.046875
Przemyśl 450.140625
București 469.859375
Ploiești 357.65625
Rzeszow 431.046875
Przemysl 450.140625
[22]

Find the brightest rows in the sign crop

Compute a simple mean-luminance profile across the sign to identify rows most affected by bright text, arrows and reflective elements.

Python
import numpy as np, matplotlib.pyplot as plt, pandas as pd
arr=np.array(img)
# crop original sign
sc=arr[45:185,260:500]
# luminance?
lum=sc.mean(2)
# print rough rows/cols max averages
rows=lum.mean(1)
[(i+45, rows[i]) for i in np.argsort(rows)[-20:]]
Output
[(72, 186.79166666666666),
 (99, 186.95416666666668),
 (81, 187.18125),
 (97, 187.42604166666666),
 (98, 187.42604166666666),
 (54, 187.46145833333333),
 (55, 187.72708333333333),
 (73, 187.91979166666667),
 (74, 188.79583333333332),
 (80, 189.0625),
 (75, 189.409375),
 (76, 189.92604166666666),
 (79, 189.94479166666666),
 (77, 190.36458333333334),
 (78, 190.37916666666666),
 (170, 191.69166666666666),
 (166, 193.653125),
 (167, 198.89583333333334),
 (169, 200.14270833333333),
 (168, 201.58854166666666)]
[23]

Crop and enhance the dealership facade

Make a tighter crop of the Nissan/adjacent dealership facade to compare branding and architecture against candidate locations.

Python
dcrop=img.crop((1030,125,1630,400)).resize((1200,550))
dcrop=ImageEnhance.Sharpness(dcrop).enhance(2)
dcrop=ImageEnhance.Contrast(dcrop).enhance(1.3)
dcrop.save('/mnt/data/dealer_crop.png')
Output
dealer_crop.png
dealer_crop.png
[24]

RGB-region statistics: first attempt

Calculate mean and median RGB values for small sign regions. This failed because the current image array had a channel shape incompatible with reshape(-1, 3).

Python
import numpy as np
a=np.array(img)
# show mean RGB for regions
regions={'leftshield':(270,55,323,114),'rightshield':(420,70,473,123),'bg':(330,60,390,100)}
for name,(x1,y1,x2,y2) in regions.items():
    r=a[y1:y2,x1:x2]
    print(name, r.reshape(-1,3).mean(0), np.median(r.reshape(-1,3),axis=0))
Output
ValueError: cannot reshape array of size 12508 into shape (3)
[25]

RGB-region statistics: corrected conversion

Force the Pillow image to RGB before converting to NumPy, then recompute the region statistics successfully.

Python
a=np.array(img.convert("RGB"))
for name,(x1,y1,x2,y2) in regions.items():
    r=a[y1:y2,x1:x2]
    print(name, r.reshape(-1,3).mean(0), np.median(r.reshape(-1,3),axis=0))
Output
leftshield [ 89.82795011 192.17236968 229.81771666] [ 89. 192. 230.]
rightshield [ 50.23246707 178.84620862 219.26877892] [ 36. 186. 221.]
bg [ 78.15541667 182.68458333 224.75791667] [ 79. 190. 228.]