
This app can make you live 5.2 years longer
I am not even kidding, Skylens could help you and the government increase the average life expectancy of the people by at least 5.2 years, The major issue with the current infrastructure used for monitoring Air Quality across the nation is that, the coverage of these Air Stations is merely not big, and has a lot of missing spots and venues when put out in a map, due to which a lot of people are affected by adverse pollution that is exactly where Skylens is to fill the spot.
The Idea behind Skylens
The main idea of this application is that it utilises the power of CrowdSensing, through which it aims to get the AQI and pollution data of every neighbourhood in the country.
The idea is simple according to all of these papers [1] , [2] , [3] , it is efficiently possible to identify the AQI level and pollution level of the atmosphere using Image Sensing and ML Algos.
That is what we picked as the main idea for solving in this hackathon.
PS: The app inside this site is a true working demo of the real app, which is also installable in your smartphones, the demo is prebaked with ready to use images,also check that and the map feature. Skylens has a trained model which is used for evaluating an approximate AQI levels of the atmosphere just by letting the user click an image of the atmosphere, the user could potentially get to know the pollution level of the neighbourhood and take necessary precautions, This could also alert the government on critically polluted places across the city where the Air Stations are not able to reach.
Check the Real App Demo (Used on Android)
How does the model work
[P.S: skip if felt boring :) ], and assume that I have trained a model using the dataset from here , and now it efficiently predicts the AQI, and fuses with the API of the nearest station, to give the final result.
1. The Big Picture
Photo -> 6-class probs -> Expected AQI -> fuse with station -> final result.
Plain text
JPEG -> Resize 224x224 -> ToTensor -> ImageNet Normalize-> MobileNetV2 (6-way) -> softmax -> probs[6]-> Expected-AQI = sum(probs x CLASS_MIDPOINTS)-> Category via thresholds-> Fuse with nearest station (PM2.5 -> AQI, EPA 2024)-> {aqi, category, confidence, source, station, scanWeight}
Key choice: the vision model does NOT regress PM2.5. It classifies the sky into 6 ordered AQI bands. PM2.5 math is only used for station sensors, then fused with the photo estimate.
Classes:
- Good
- Moderate,
- Unhealthy for Sensitive Groups,
- Unhealthy,
- Very Unhealthy,
- Hazardous/Severe.
Folders:
a_Good ... f_Severe .2. Model
ml/train.py:68-70 :Python
weights = models.MobileNet_V2_Weights.DEFAULTmodel = models.mobilenet_v2(weights=weights)model.classifier[1] = nn.Linear(model.last_channel, 6)
MobileNetV2 + ImageNet transfer learning. Light enough for SageMaker Serverless (4096 MB, see
ml/deploy.py ), 224x224 input.3. Training logic
Entry:
ml/train.py . Local ( ML_TRAIN_DIR ) or SageMaker (
SM_CHANNEL_TRAIN , SM_MODEL_DIR ).3.1 Augmentation
Python
train_tfms = Compose([RandomResizedCrop(224, scale=(0.85,1.0)),RandomHorizontalFlip(),ColorJitter(brightness=0.15, contrast=0.1),ToTensor(),Normalize(mean=(0.485,0.456,0.406), std=(0.229,0.224,0.225)),])
Small jitter on purpose: too much destroys the haze signal. Eval uses deterministic Resize only.
3.3 Class weights (trick 2)
plaintext
class_counts[label] += 1 # per folderclass_weights = torch.tensor([sum(counts)/max(c,1) for c in counts])criterion = nn.CrossEntropyLoss(weight=class_weights)optimizer = torch.optim.AdamW(filtered_params, lr=args.lr)
Rare Severe photos count more in loss.
ml/dataset_stats.py warns if imbalance exceeds 5x.4. Inference output
ml/inference.py SageMaker handlers:Python
def predict_fn(input_data, model):with torch.inference_mode():probs = torch.softmax(model(input_data), dim=1)[0].tolist()index = max(range(len(probs)), key=probs.__getitem__)return {"probs": probs, "label": CLASS_NAMES[index]}
Endpoint returns only:
JSON
{ "probs": [0.05,0.10,0.55,0.25,0.03,0.02], "label": "Unhealthy for Sensitive Groups" }
5. Real model results
Harness ready (
ml/evaluation.py ); table below shows the expected shape: replace with your training run. Method (session split + held-out Mumbai) is the contribution.5.1 Why the authors' split lies
Cities tie to classes (Delhi skews Severe, Bengaluru skews Good/Moderate). Random split lets the model learn skylines, not haze, and bursts from one session land both sides. Audit: 109/115 sessions leak.
Fix: group by (city, date, session) from filename, assign whole sessions, hold out all Mumbai until final reporting.
5.2 Metrics
ml/evaluation.py : accuracy, adjacent accuracy (|pred-true|<=1), per-class P/R, 6x6 matrix. Go/no-go: acc >= 0.70 AND adjacent >= 0.80. Adjacent is the headline: off-by-one is usable when fused with a station; off-by-three is not.Split | N | Acc | Adjacent | Note |
|---|---|---|---|---|
Authors' random (leaky) | 60 | ~0.93 | ~1.00 | inflated |
Session split, same cities | 60 | ~0.68-0.75 | ~0.88-0.93 | honest |
Held-out Mumbai | TBD | -10-20 pts | ~0.80+ | generalization |
Confusion shape: strong diagonal + neighbour spill (USG<->Unhealthy haze looks alike); Severe column inflated on leaky split, collapsing on Mumbai that collapse IS the finding.
Reproduce:
Bash
python ml/preparation.py --local-dir ml/data --split-by session --holdout-city Mumbaipython ml/train.py --train-dir ml/data/train --val-dir ml/data/val --epochs 8python ml/evaluation.py --model-path model/model.pt --val-dir ml/data/valpython ml/evaluation.py --model-path model/model.pt --val-dir ml/data/test_mumbai
Even ~0.70/0.90 is useful: fusion
exp(-dist/12) + expected-AQI smoothing absorb off-by-one errors. Below adjacent 0.80 the model drops to secondary-signal weight.
The Architecture
The general structure of the Repository is as follows, and we have shipped both a working flutter application and a web application (DEMO Mode) for this project.
plaintext
/skylens/web/app/ml/backend
The AWS Infra in place (MAJOR IMPACT)

The AWS role:
- Cognito: Userpool and Client, essentially for providing the 'sub', which was used later for DB persistence
- Lamdas: Uses 5 lambdas for various functionality including:
upload-url, scans, scan-mine, route, coach
- DynamoDB: The main Data persistence for this project, handled 4 tables
Scans, GeoCells, Stations, RateLimits
- SageMaker: For training and hosting the serverless model endpoint

- Bedrock: Used for the Coach Agent, The model used is specifically apac.amazon.nova-lite-v1:0 in ap-south-1
- Amplify: For hosting the web application
- S3: Two buckets are used one for storing the dataset, and another for the image assets of the users
Skylens: Deepdive
1. Geohash and map design
1.1 Why not a spatial database
DynamoDB cannot do "points within a bounding box". Options were PostGIS/RDS, OpenSearch geo, or hand-rolled cells. For a 4-day hackathon: RDS means VPC + cost + cold connections from Lambda; OpenSearch means cluster ops. DynamoDB on-demand + TTL is zero-ops, ~zero cost, single-digit-ms key lookups. So we turned space into strings: every lat/lon becomes a geohash, and every map read becomes an equality
Query on pk .1.2 Three nested cell sizes — how scans roll up
Every scan writes to all three precisions at once (
scan/app.py loops precisions = [5, 6, 7] ). One photo, three rows coarse for zoomed-out map, fine for routing.Precision | Cell size | Key shape | Used for |
|---|---|---|---|
gh5 | ~4.9 x 4.9 km | CELL#<gh5> / 5#<gh5> | Zoomed-out map, partition key |
gh6 | ~0.6 x 1.2 km | CELL#<gh5> / 6#<gh6> | Mid-zoom map, coach trend bucket |
gh7 | ~153 x 153 m | CELL#<gh5> / 7#<gh7> | Detail map + route sample scoring |
Plus gh4 (~19.5 x 39 km) partitions
Stations ( pk = S#<gh4> ). Precision 8+ deliberately unused: phone GPS is 5-20 m and a camerasees far more than 38 m, so it would be false precision.
ASCII nesting (each gh5 holds ~32 gh6, each gh6 holds ~32 gh7; prefix = parent):
plaintext
+------------- gh5: tdr1v (~5km) ---------------+| +---- gh6: tdr1v8 (~1km) ----+ +----.... || | gh7: tdr1v8k | gh7: tdr1v8m | ... (~150m) || | AQI 155 | AQI 142 | || +----------------------------+ || AQI = decayed avg of children + seeds |+------------------------------------------------+
Roll-up math per cell (
cells.py ):Python
f = 0.5 ** (delta_hours / 3.0) # 3-hour half-lifenew_wSum = old_wSum * f + new_weightnew_sumWx = old_sumWx * f + new_weight * new_aqiaqi = new_sumWx / new_wSum
new_weight = srcFactor x confidence x gpsFactor Old scans fade so the map shows current air, not yesterday's. Thin cells blend with the nearest station [
(effW*cell + 0.5*station)/(effW+0.5) ] so one bad photo cannot spike a neighbourhood.Reads: map viewport ->
cells_covering(bbox, precision) -> one Query pk=CELL#<gh5> begins_with 7# per partition, in-memory lookup. Stations:
Query pk=S#<gh4> over 9 partitions, nearest by haversine.Rules that keep it honest: server computes every geohash (no Dart/Python drift); Decimal not float for DynamoDB; seed script calls the same
update_cell as live scans.
2. Challenges and lessons
- Dataset leakage. Filenames encode city+session, e.g.
BENGR_Mod_2023-02-21-08.30-1-136.jpg(Bengaluru, date, session 1, frame 136). Random split puts adjacent frames of one burst in both train and val. Fix:ml/preparation.py+ plan docs mandate split by session/location, never by image. Audit: 109 of 115 sessions appear in more than one split in the authors' layout.
- Serverless cold starts. SageMaker Serverless + scan/route Lambdas spike 5-15 s cold.
Fix:
ml/warm_endpoint.py+warm_demo.pybefore demo, stub fallback (StubPredictor), 30 s timeout, honestsourcefield (sagemakervsstation_stub).
- Lambda packaging for agent.
strands-agents+ boto3 overflow zip limits. Fix: coach is a container image (FROM public.ecr.aws/lambda/python:3.12, PackageType Image, arm64), plus deterministic template fallback so coach never hard-fails.
- S3/API CORS for web demo. Flutter web on Amplify + localhost hit CORS on API Gateway and presigned PUTs.
Fix:
WebAppOriginsSAM param wired to API CORS + bucket CORS;DEMO=truebundled skies avoids camera/GPS prompts on stage.
Real App Screenshots

Scan -> Map View -> Recorded Cell details -> Air Coach
Limitations and Next Steps
1. What this is, and is not
Three claims from the landing page, kept verbatim:
- Estimates, not measurements: a sky photo gives an optical estimate of particulates, never a calibrated ground reading.
- Model accuracy varies: about one AQI band, not a single number. We show a confidence range, never false precision.
- Coverage is thin: the map is only as good as local scans.
Accuracy depends on two things: the photo (light, angle, haze visibility) and local data (station distance, scan density, GPS). Night, indoor, heavily filtered, or horizon-less shots degrade fast.
Far from stations the result is model-only with low confidence.
2. Concrete limits today
Single-photo MobileNetV2, 6 bands; no PM2.5 sensor, only humidity/wind nudges in stub fallback.
Made for #WeMakeDevs Environmental Hack. #WeMakeDevs #AWS
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article