EuroSAT (Sentinel‑2) Land Use / Land Cover — Fast CNN Lab
Learning Objectives: Learn how to load a geospatial dataset (EuroSAT, RGB), explore it, and train several CNN for image/patch classification (LeNet-5, VGG, MobileNetV2).
Dataset overview & metadata
Name: EuroSAT (RGB variant via tensorflow_datasets)
Task: Land use / land cover (classification) on Sentinel‑2 patches
Why here? Small, geo‑relevant, and relatively quick to train for an in‑class lab.
Plan
1. Load EuroSAT & inspect metadata.
2. Visualize random samples & check class balance.
3. Train a tiny LeNet‑style CNN in grayscale 32×32×1 (super fast).
4. Evaluate accuracy + confusion matrix; show predictions vs ground truth.
5. Repeat for a number of other CNN. 6. Try out transfer learning of a pretrained CNN model.
NOTE: By now, we’ve moved onto the most sophisticated types of AI algorithms in computer vision (image classification and segmentation with deep neural networks) and are about to apply this to a realistic geoscience dataset of labeled satellite imagery. We cannot directly input full-size satellite images into any type of ML/DL algorithm, because these images typically have of the order of \(10,000\times 10,000 = 10^8\) pixels and multiple spectral bands. Remember that every pixel and every value of that pixel (e.g., RGB reflectances) are a feature. The feature array for any ML/DL algorithm can be thought of as a spreadsheet with \(m\) rows and \(n\) columns, where \(m\) is the number of samples, which in this case is the number of satellite images, and \(n\) is the number of features, which in this case is the order of \(10^8\) - \(10^9\) pixel values.
Mostly for RAM/GPU memory reasons, but also computational cost, we cannot directly do full gradient descent on this amount of data at once. Instead, we have to make two simplications to train a model.
We have to chop up the huge native sizes of satellite images into smaller tiles/clips to reduce the number of features (pixels) to consider at once. In this lab, we train models on either \(32\times 32\) or \(64\times 64\) image clips of larger images.
We use mini-batch gradient descent to find the global minimum of our loss function, i.e. smallest fitting error. Remember what that means. Say, we had the luxury of some enormous set of training data with a million labeled images. A ‘regular’ gradient descent method would try to fit a model (logistic regression, ANN, CNN, random forest) to all those million images all at once in a single iteration. In mini-batch gradient descent, we instead define some batch size of, for example, 128 images and only fit to 128 images at a time. This is explained in one of our earlier lectures and again in one of the hints further down in this notebook.
Despite these optimizations, the problem that we try to solve in this lab would normally take hours of computational time to model with high accuracy. In an effort to make this feasible for a short lab, e.g., by only training for a small number of epochs, you can see how these CNN work but you will see some relatively low accuracies, especially in the beginning. If you’re frustrated and/or curious how well these models can actually perform, you can re-run some of the sections later at home, e.g., overnight, to get better results.
Import libraries
Code
import os, time, math, re, randomimport numpy as npimport matplotlib.pyplot as pltimport tensorflow as tfimport tensorflow_datasets as tfdsimport pandas as pdfrom collections import Counterfrom tensorflow import kerasfrom tensorflow.keras import layersfrom sklearn.metrics import confusion_matriximport seaborn as sns%matplotlib inline
Code
SEED =42random.seed(SEED)np.random.seed(SEED)tf.random.set_seed(SEED)AUTOTUNE = tf.data.AUTOTUNEBATCH_SIZE =128IMG_SIZE =32# for LeNet
Download dataset
This dataset is not that small (2GB, I believe), so the first time you run the code below it may take a while to download. The full dataset contains 27,000 images, which would also take too long to train on within a lab. To reduce download and training times, the code below allows you to choose how many training and test images you want to use and only download those. Actually, to give you more flexibility, we will download a bit more data than we need initially, so you can play around with how much training/test data you want to use for different models.
The code also prints some other information about the EuroSat dataset.
Note that this dataset uses an optimized tensorflow structure such that each clip/tile is only loaded during training when it is needed (it basically defines a pipeline which is only executed when you start model training).
As a result, it is not quite as straightforward as before to see how many samples we have and what the pixel dimensions and nr of bands each individual image has. Here’s a way to still get that information:
Explanation of how this works
🔁 Understanding next(iter(train_raw.take(1))) and Iterable Datasets
In TensorFlow, datasets created with tf.data.Dataset (such as train_raw from TensorFlow Datasets) are iterable objects — meaning they produce data one batch or one example at a time when you loop over them.
They behave much like Python generators.
When we write:
next(iter(train_raw.take(1)))
here’s what happens step by step:
train_raw
is a tf.data.Dataset that yields pairs of (image, label) tensors when iterated over.
.take(1)
creates a new dataset that contains only the first element from train_raw.
iter(...)
converts that dataset into a Python iterator, allowing you to manually pull out examples instead of looping over the dataset.
next(...)
retrieves the first batch or example from the iterator — in this case, a single (image, label) pair.
This expression effectively grabs one sample from the dataset for quick inspection or debugging, for example:
This lets you check the pixel dimensions and label values before you build or train your model.
In short:
- tf.data.Dataset is lazy and iterable — it doesn’t load all data at once.
- iter() and next() let you peek inside the dataset interactively.
- .take(n) lets you look at only a few examples without consuming the whole dataset.
Code
# --- Sanity check ---train_count = train_raw.cardinality().numpy()test_count = test_raw.cardinality().numpy()# Extract one example to inspect pixel dimensionsexample_img, example_label =next(iter(train_raw.take(1)))img_shape = example_img.shapeprint(f"\nTrain size: {train_count}, Test size: {test_count}")print(f"Image dimensions: {img_shape[0]} x {img_shape[1]} pixels, {img_shape[2]} channels\n")
Visualize what your data looks like
The code below allows you to extract nr_img_to_visualize images and their labels from the full dataset that you downloaded.
Check the shape of the image array and the associated labels and remember that we define a variable earlier, class_names, that you can use to map integer class labels to their corresponding names of actual Land-Use-Land-Cover categories (e.g., starting from 0, label 1 corresponds to “Forest”).
Exercise: using plt.subplot, can you visualize a number of images and have the corresponding class names (as words/strings) as the title for each sub-plot? As an example, you could select nr_img_to_visualize=24 and plot the images in 4 rows and 6 columns of sub-plots.
Hint
plt.imshow(imgs_rgb[i])
Code
# ---- Visualize a small batch (RGB before any conversion) ----nr_img_to_visualize =24batch =next(iter(train_raw.shuffle(4096, seed=SEED).batch(nr_img_to_visualize)))imgs_rgb, labels = batchimgs_rgb = imgs_rgb.numpy()labels_np = labels.numpy()
For any number of images (such as the batch above), we can print how many images we have of each class. This can provide insight into class imbalances.
Explanation: If you train on a dataset which has 1000 images of one class and only 3 of another class, you can imagine that it will end up performing much better for the former than the latter class. Having decent class balance in your training data is another important component of train accurate AI models.
Repeat the above analysis (which was for a small batch), but now for all training data and then for all test data to check for class imbalances.
Code
# Analyze class imbalances in training and test data
Classic LeNet‑5 model
We first explore the LeNet-5 model, which is the first well-known CNN. This model is explained in great detail in the lecture notes jupyter notebook. The original LeNet-5 model was built for grayscale images and required input image sizes of 32×32 pixels.
We therefore convert RGB → grayscale, resize to 32×32, and then also scale from image values of 0 - 255 (standard for many image formats) to \([0,1]\).
We define a compact LeNet‑like architecture to keep runtime short. This is the same model as in the lecture jupyter notebook, which also applied this model to the MNIST handwriting dataset.
Train the model for 10 epochs and see how it performs.
Code
%%timeEPOCHS =10# bump up if you have time/GPUmodel = build_lenet()lenet5_trained = model.fit(train_gray, validation_data=test_gray, epochs=EPOCHS, verbose=1)
Question: do you understand what the output means?
Hint 1
What type of optimization method do you think these CNN use to find the weights/fitting parameters with the smallest fitting error / loss?
Answer
Mini-batch gradient descent
Hint 2
What is the total number of training samples and what is the batch-size that you used for mini-batch gradient descent? Does that explain the number of iterations per epoch?
Answer
If you didn’t change the default settings yet, we may have 2000 training samples/images and use a batch-size of 128. That means that we ‘fit’ 128 images at a time in a loop until we have seen all images. That loop would take 2000/128 = 15.625 iterations. A loop can only run an integer number of times, so we would run if for 16 iterations and the last iteration would only have 2000 - 15*128 = 80, instead of 128, samples/images in it. All this is one epoch. For regular gradient descent, which you are perhaps more familiar with now, we would ‘fit’ to all images at once. So one epoch of mini-batch gradient descent is essentially equivalent to one iteration of full-batch gradient descent.
To evaluate the accuracy in the simplest possible way, we could run our model in inference mode (model.predict), which is the prediction mode.
We do this for all the training data first to see how well our model managed to fit our training data. However, this is not the most insightful. We want to see how well the model generalizes for future measurements (satellite images in the future). For that purpose, we have set aside a test set of images that were not part of the model training.
Once we make predictions for each of those image sets, we could compare those predictions to the actual labels (ground truth) to get accuracy metrics. The python AI/ML/DL packages make all this even more convenient, in a black-box way, and you can run model.evaluate() instead, which hides the step of making predictions and then comparing to ground truth labels, and instead gives you the loss and accuracy in one step.
Of course, you’re encouraged to try if you can get the same results with actual model predictions and comparing to the true labels.
By training the model with lenet5_trained = model.fit(...), we also saved the ‘history’ of how the model training progressed. Specifically, we can plot learning curves of how the loss function (fitting error) reduced and the accuracy increased as a function of the number of epochs that we trained the model for.
Answer
This is a fairly complex problem and we used a small CNN on only grayscale imagery. You can imagine that it is hard to identify grass from crops from just grayscale images, for example.
Question: how do you interpret the similarity or difference between the learning curves for training data versus validation data?
[You may have to train for more epochs to properly assess this, but don’t spend too much time on that for now.]
Train a tiny LeNet‑style CNN (RGB)
EuroSAT has color. See if a slightly modified LeNet-5 model that uses all color bands improves results:
Exercise: Repeat the same steps as for the grayscale model:
Train model
Visualize learning curves
Report final accuracy on training and validation data
Train this model.
Code
%%timeEPOCHS =10# bump up if you have time/GPUdef to_lenet_rgb(img, label): img = tf.image.resize(tf.cast(img, tf.float32), (IMG_SIZE, IMG_SIZE), antialias=True) /255.0return img, labeltrain_rgb = (train_raw .shuffle(10000, seed=SEED) .map(to_lenet_rgb, num_parallel_calls=AUTOTUNE) .batch(BATCH_SIZE) .prefetch(AUTOTUNE))test_rgb = (test_raw .map(to_lenet_rgb, num_parallel_calls=AUTOTUNE) .batch(BATCH_SIZE) .prefetch(AUTOTUNE))rgb_model = build_lenet_rgb()history_rgb = rgb_model.fit(train_rgb, validation_data=test_rgb, epochs=EPOCHS, verbose=1)rgb_acc = rgb_model.evaluate(test_rgb, verbose=0)[1]*100print(f"EuroSAT (RGB LeNet‑ish) — Test Accuracy: {rgb_acc:.2f}%")
Look at the learning curves again:
Code
# Plot learning curves just like we did for the grayscale LeNet model.# Make sure to check that you're using the correct variable names, e.g. for the history of training the RGB model
Question 1: Have the results improved much? Why / why not?
Question 2: How about the relation between the training vs validation learning curves?
Evaluate: confusion matrix & prediction gallery
Next, we look at some other visualizations of model performance.
First, we construct the confusion matrix. Remember, for each class, the confusion matrix compares the model predictions to the true labels.
Code
# --- Predictions ---y_prob = rgb_model.predict(test_rgb, verbose=0)y_pred = np.argmax(y_prob, axis=1)# --- True labels (collect directly from raw RGB test split) ---y_true = np.concatenate([y.numpy() for _, y in test_raw.batch(2048)], axis=0)[:len(y_pred)]# --- Confusion Matrix ---cm = tf.math.confusion_matrix(y_true, y_pred, num_classes=len(class_names)).numpy()plt.figure(figsize=(7,6))plt.imshow(cm, interpolation="nearest", cmap="Blues")plt.title("Confusion Matrix (RGB LeNet-ish)")plt.xlabel("Predicted")plt.ylabel("True")plt.xticks(ticks=np.arange(len(class_names)), labels=[c[:8] for c in class_names], rotation=45, ha="right")plt.yticks(ticks=np.arange(len(class_names)), labels=[c[:8] for c in class_names])plt.colorbar(fraction=0.046, pad=0.04)# Annotate matrix with countsfor i inrange(len(class_names)):for j inrange(len(class_names)): val = cm[i, j]if val >0and (i == j or val >= cm.max() *0.05): plt.text(j, i, int(val), ha="center", va="center", fontsize=8, color="white")plt.tight_layout()plt.show()
The better the model, the more all samples should fall on the diagonal where predicted class == true class. Instead, if we look at the bottom-left, for example, we may see that 14 samples were classified as “Forest” when in reality they were of a “SeaLake”.
The visualization makes it very clear which categories the model is identifying well and which it is struggling with. You are likely to see, for example, that models have a hard time distinguishing rivers from roads, which is not suprising.
Next, we can show some example images, how our model classified the images, and what the ground-truth labels were.
Pay attention that this time we are in fact explicitly using the .predict() mode, which returns the probabilities for each image belowing to each of the possible classes. preds = np.argmax(probs, axis=1) then takes the highest probability for each image and uses that as the final (integer) classification label.
Code
# === Prediction gallery ===# Show sample predictions vs ground truth on RGB imagesbatch_imgs, batch_labels =next(iter(test_rgb))probs = rgb_model.predict(batch_imgs, verbose=0)preds = np.argmax(probs, axis=1)N =min(24, batch_imgs.shape[0])cols =6rows =int(np.ceil(N / cols))plt.figure(figsize=(cols *2.2, rows *2.2))for i inrange(N): plt.subplot(rows, cols, i +1) img = batch_imgs[i].numpy() plt.imshow(img) # RGB image t, p =int(batch_labels[i].numpy()), int(preds[i]) color ="green"if t == p else"red" plt.title(f"P: {class_names[p]}\nT: {class_names[t]}", color=color, fontsize=9) plt.axis("off")plt.tight_layout()plt.show()
What do you think?
VGG-type CNN model (Optional)
VGG is a second-generation CNN model. It is quite powerful but not super efficient. It can achieve high accuracy on this dataset but may need more samples and more epochs, which takes a while to run. In a chronological sense, it belongs here in the lab, but I encourage you to explore the next section on MobileNetV2 first and then return here, time permitting.
Information about the VGG CNN model
VGG is one of the classic deep convolutional neural network (CNN) architectures, developed by researchers at the Visual Geometry Group (VGG) at the University of Oxford in 2014.
It became widely known after achieving excellent results in the ImageNet Large-Scale Visual Recognition Challenge (ILSVRC-2014).
The key idea behind VGG was to show that simply stacking many small convolution filters (for example, 3×3 filters) could dramatically improve performance compared to earlier, shallower networks.
This was one of the first demonstrations that depth alone—using more layers with simple building blocks—could lead to much stronger image-classification accuracy.
🔍 Why it’s important
It helped establish the design pattern of deep, uniform convolutional blocks followed by pooling and fully connected layers.
VGG networks (e.g., VGG-16 and VGG-19) became the foundation for many later CNN architectures.
Although VGG models are relatively large and computationally heavy, they are conceptually simple and easy to understand—making them a popular teaching example.
In practice, modern models such as ResNet, MobileNet, and EfficientNet have since improved efficiency and accuracy, but VGG remains an elegant and influential milestone in the evolution of deep learning for vision.
This model is also nice because it can accommodate any size of input images, so we don’t have to resample to \(32\times 32\) pixels.
%%time# --- Data preprocessing ---IMG_SIZE =64BATCH_SIZE =128AUTOTUNE = tf.data.AUTOTUNESEED =42# --- Train & evaluate ---EPOCHS =10# 10–15 gives great results even on Colabvgg_model = build_vgg_lite()vgg_training = vgg_model.fit(train_ds, validation_data=test_ds, epochs=EPOCHS, verbose=1)
Print accuracy. The code below shows how to both show the accuracy at the end of training for EPOCHS epochs, as well as the highest accuracy during training. When using mini-batch gradient descent, the loss and accuracy can go up and down during training and it is also possible/common for the training accuracy to be lower but the validation accuracy higher (or vice versa) for any given epoch, so the highest accuracy on validation data may not be the same those after the final epoch.
Code
# --- Accuracy summary ---final_train_acc = vgg_training.history["accuracy"][-1]final_val_acc = vgg_training.history["val_accuracy"][-1]print(f"Final training accuracy: {final_train_acc:.3f}")print(f"Final validation accuracy: {final_val_acc:.3f}")best_epoch = np.argmax(vgg_training.history["val_accuracy"]) +1best_train_acc = vgg_training.history["accuracy"][best_epoch -1]best_val_acc = vgg_training.history["val_accuracy"][best_epoch -1]print(f"Best epoch: {best_epoch}")print(f"Training accuracy at best epoch: {best_train_acc:.3f}")print(f"Validation accuracy at best epoch: {best_val_acc:.3f}")
Plot learning curves again:
Code
# Plot learning curves for your VGG model training
⚙️ MobileNetV2
Finally, we try a more recent (2018) CNN developed by Google that is both accurate and fast enough to use in a single lab session like this. This model should give you the best results so far.
What is MobileNetV2
MobileNetV2 is a lightweight convolutional neural network (CNN) architecture designed for both efficiency and strong accuracy.
It was introduced by Google in 2018 as an improvement over the original MobileNet, targeting applications on mobile and embedded devices where computational resources are limited.
🧠 Key ideas
Depthwise Separable Convolutions
Instead of using a full 3D convolution for every filter, MobileNetV2 splits the operation into:
A depthwise convolution (one filter per input channel), followed by
A pointwise convolution (1×1 conv to mix channels).
This dramatically reduces computation and the number of parameters without much loss in accuracy.
Inverted Residual Blocks
Traditional residual networks (like ResNet) connect wide layers to wide layers.
MobileNetV2 instead connects thin (low-dimensional) layers via bottlenecks, expanding and then compressing the feature maps within each block.
This structure improves gradient flow while keeping the model compact.
Linear Bottlenecks
Nonlinear activations (like ReLU) are avoided at the narrowest layers to prevent information loss.
This keeps feature representations more expressive, especially in low-dimensional spaces.
Global Average Pooling (GAP)
After the final convolutional block, MobileNetV2 uses a GAP layer that averages each feature map to a single value.
This removes the need for large fully-connected layers and further reduces parameters.
⚡ Why it’s used here
MobileNetV2 achieves an excellent balance between speed, size, and accuracy: - It’s small enough to train quickly in a classroom setting (even on CPU or Colab GPU).
- It’s pretrained on ImageNet, so its convolutional filters already capture general visual features.
- It adapts easily to smaller images (like 64×64 EuroSAT tiles) when used as a frozen feature extractor in transfer learning.
In summary, MobileNetV2 provides high-quality visual features at a fraction of the computational cost of large CNNs like VGG or ResNet — making it ideal for quick experiments, mobile deployment, and educational demonstrations.
Increase the amount of training/validation data to try and reduce overfitting (note that we downloaded more training/test imagery than we used so far for the smaller LetNet and VGG models).
Code
# --- user-defined sizes ---N_TRAIN =5000# pick something like 1000–5000 for fast labsN_TEST =1000# --- create subsets ---train_raw = train_full.take(N_TRAIN)test_raw = test_full.take(N_TEST)
Note, we’re doing some new and more advanced here: transfer learning. The line weights="imagenet" below loads in the training weights (fitting parameters) of the MobileNetV2 model after already training it on a different dataset: imagenet.
More about imagenet
🧠 About the ImageNet Dataset
ImageNet is one of the largest and most influential image datasets in computer vision.
It was created to support large-scale visual recognition research and has been the foundation for many deep-learning breakthroughs.
Scale: over 14 million labeled images.
Classes: more than 20 000 object categories, organized according to the WordNet hierarchy.
Common benchmark subset: the ImageNet-1K set used for competitions like the ImageNet Large-Scale Visual Recognition Challenge (ILSVRC) contains 1 000 object categories (e.g., dog, airplane, banana, keyboard).
Image variety: objects appear under different lighting, backgrounds, and viewpoints, which helps models learn robust, general-purpose visual features.
Because ImageNet is so large and diverse, models trained on it (like VGG, ResNet, MobileNet, and EfficientNet) learn to detect generic features such as edges, textures, shapes, and color gradients.
These low-level features are useful for almost any visual task — including satellite or aerial imagery — even though ImageNet itself contains natural photographs rather than remote-sensing data.
This is why we can use transfer learning:
we take a model pretrained on ImageNet, keep the learned feature extractor (the convolutional base), and only retrain a small classification head for our own dataset (for example, EuroSAT land-use categories).
In transfer learning, the idea is that a model has already learned how to construct useful filters that can detect edges, gradients, etc. We can then fine-tune this model for a new application. Using this approach, one typically needs far fewer iterations/epochs of training compared to training a model from scratch. You can verify this for yourself by changing weights="imagenet" to weights=None and then training for a small nr of epochs (likely much lower accuracy) or a larger nr of epochs (increasing accuracy but of course taking longer).
Code
# --- Model: pretrained MobileNetV2 base ---base = keras.applications.MobileNetV2( include_top=False, input_shape=(IMG_SIZE, IMG_SIZE, 3), weights="imagenet",# weights=None, pooling="avg"# global average pooling)base.trainable =False# freeze base for speedmodel = keras.Sequential([ base, layers.Dense(128, activation="relu"), layers.Dropout(0.3), layers.Dense(10, activation="softmax")])model.compile(optimizer=keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"])
Print accuracies on training and validation data both at the end of training (last epoch) and the best epoch, just like we did above for the VGG model.
Code
# Print accuracies on training and validation data from the training history
Or by running inference on validation data again:
Hint
model.evaluate()
Code
# run inference over validation/test data and prints the accuracy
Plot the learning curves again just like before, i.e. from the training history.
Code
# --- Plot learning curves ---
Question: what do these learning curves suggest (especially if you train for more epochs)? If you define an issue, how could this be addressed? Time permitting, you can try it out.
Comment: Note how high the accuracy already is at the first epoch(s)! This is due to the transfer learning. The features/filters learned from training on the imagenet dataset indeed transfer over to this entirely different dataset, to a degree, and then we just need to fine-tune for this specific dataset and application.
Can you plot the confusing matrix for this model when applied to test data, similar to how we did it earlier? Pay attention to variable names.
Code
# Plot confusion matrix
You should see that the confusion matrix is now much more clustered around the diagonal, which is what we want.
Visualize performance on some example test images; use the same python tricks as earlier.
Code
# --- Visualize predictions on a batch of test images ---# in the title, show predicted label and real/ground truth label
🧭 Summary and Take-Away Points
Goal: In this lab, we applied convolutional neural networks (CNNs) to classify satellite imagery from the EuroSAT land-use/land-cover (LULC) dataset.
Each image represents a 64×64 px Sentinel-2 tile labeled into one of ten land-cover classes (e.g., residential, forest, water, cropland).
Key steps:
Loaded and preprocessed EuroSAT images with TensorFlow Datasets (TFDS).
Converted RGB imagery to normalized tensors and batched data for efficient training.
Implemented simple CNNs such as LeNet-5 and later tried deeper networks like VGG-lite and MobileNetV2.
Compared results across grayscale vs. RGB inputs and explored the impact of model depth and transfer learning.
Visualized learning curves, confusion matrices, and prediction examples to assess model behavior.
Concepts reinforced:
How convolutions, pooling, and fully connected layers combine to extract spatial features.
The role of epochs, batch size, and mini-batch gradient descent during optimization.
The benefit of transfer learning — reusing pretrained ImageNet weights to accelerate convergence and improve accuracy even with limited data.
How overfitting and underfitting can appear in the learning curves and how validation accuracy helps detect them.
Performance insights:
Even small CNNs like LeNet-5 achieve respectable accuracy on EuroSAT when trained on grayscale inputs.
Using full-resolution RGB imagery and deeper networks (VGG, MobileNetV2) can substantially improve performance, though gains depend on data size and augmentation.
Pretrained models generally converge faster and generalize better.
Big picture:
This workflow illustrates the power of deep learning for remote-sensing applications, where satellite imagery can be automatically categorized into meaningful land-cover classes — a fundamental capability for environmental monitoring, agriculture, and urban mapping.
🧠 Next step ideas: experiment with data augmentation, class balancing, or alternative pretrained architectures (e.g., ResNet, EfficientNet) to see how they affect accuracy and training dynamics.