AWS Builder Center
This app can make you live 5.2 years longer

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.
Check this paper for knowing how air-pollution affects our life:  [paper] 
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.
4 sleepless days, 23 PRs and 73 Commits later, presenting  Skylens   Deployed App   Demo Video 
 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.DEFAULT
model = 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 folder
class_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 Mumbai
python ml/train.py --train-dir ml/data/train --val-dir ml/data/val --epochs 8
python ml/evaluation.py --model-path model/model.pt --val-dir ml/data/val
python 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-life
new_wSum = old_wSum * f + new_weight
new_sumWx = old_sumWx * f + new_weight * new_aqi
aqi = 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

  1. 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.
  1. Serverless cold starts. SageMaker Serverless + scan/route Lambdas spike 5-15 s cold. Fix:  ml/warm_endpoint.py  +  warm_demo.py  before demo, stub fallback ( StubPredictor ), 30 s timeout, honest  source field ( sagemaker  vs  station_stub ).
  1. 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.
  1. S3/API CORS for web demo. Flutter web on Amplify + localhost hit CORS on API Gateway and presigned PUTs. Fix:  WebAppOrigins  SAM param wired to API CORS + bucket CORS;  DEMO=true  bundled 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:
  1. Estimates, not measurements: a sky photo gives an optical estimate of particulates, never a calibrated ground reading.
  1. Model accuracy varies: about one AQI band, not a single number. We show a confidence range, never false precision.
  1. 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
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article