Detecting Drawing Units When $INSUNITS Is Missing

A DXF with an undefined units header offers only circumstantial evidence about its unit, so the honest procedure is: gather what evidence exists, use drawing extents to reject implausible candidates, fall back to a configured default when the remaining candidates are not separable, and record the assumption in the output. The goal is an auditable decision, not a confident one. This page is part of Unit Conversion Pipelines.

What Evidence a Drawing Actually Offers

The drafting-mode flag separates imperial from metric conventions. It narrows seven candidates to three or four and names none of them.

Three signals, none of them conclusive The circumstantial evidence an unlabelled drawing offers, what each one narrows, and its weakness. Together they usually reduce seven candidates to two; none of them names a unit on its own, which is why the outcome has to be recorded as an assumption rather than as a fact. Signal Narrows to Weakness Drafting mode flag metric or imperial family names no unit Drawing extents plausible real sizes a stray entity skews it Text height often decisive no rule, only a pattern Two candidates usually survive — the configured default decides between them.

Drawing extents are the strongest signal, because a real-world object has a plausible size. A site plan whose extents are 240 000 units is a plausible millimetre drawing, an implausible metre one and an absurd kilometre one. This rejects most candidates outright.

Text and dimension heights are the subtlest and often the most decisive. Annotation is drawn at a legible size on a printed sheet, so a text height of 2.5 in a drawing is characteristic of millimetres and a text height of 0.0025 is not.

None of these is conclusive. Together they usually narrow the field to two candidates, and the pipeline’s policy decides between them.

Production-Ready Script

# ezdxf>=1.1.0, Python 3.9+
from __future__ import annotations

from dataclasses import dataclass, asdict
import math

import ezdxf

# Candidate units, their metre factors and the plausible real-world extent range
# for a drawing of a building or a site, in metres.
CANDIDATES = {
    1: ("inches", 0.0254), 2: ("feet", 0.3048), 4: ("millimetres", 0.001),
    5: ("centimetres", 0.01), 6: ("metres", 1.0), 7: ("kilometres", 1000.0),
}
PLAUSIBLE_M = (2.0, 20_000.0)          # 2 m to 20 km spans site and building work
METRIC = {4, 5, 6, 7}


@dataclass(frozen=True)
class UnitDecision:
    code: int
    name: str
    metres_per_unit: float
    confidence: str                    # "declared" | "inferred" | "default"
    evidence: dict


def decide_units(dxf_path: str, *, default_code: int = 4) -> UnitDecision:
    doc = ezdxf.readfile(dxf_path)
    declared = doc.header.get("$INSUNITS", 0)
    if declared in CANDIDATES:
        name, factor = CANDIDATES[declared]
        return UnitDecision(declared, name, factor, "declared", {"header": declared})

    measurement = doc.header.get("$MEASUREMENT", None)   # 0 imperial, 1 metric
    extmin = doc.header.get("$EXTMIN", (0, 0, 0))
    extmax = doc.header.get("$EXTMAX", (0, 0, 0))
    span = max(abs(extmax[0] - extmin[0]), abs(extmax[1] - extmin[1]))

    text_heights = sorted({round(float(e.dxf.height), 4)
                           for e in doc.modelspace().query("TEXT MTEXT")
                           if getattr(e.dxf, "height", 0)})

    scored: list[tuple[float, int]] = []
    for code, (name, factor) in CANDIDATES.items():
        if measurement is not None:
            if measurement == 1 and code not in METRIC:
                continue
            if measurement == 0 and code in METRIC:
                continue
        real = span * factor
        if not (PLAUSIBLE_M[0] <= real <= PLAUSIBLE_M[1]):
            continue
        # Prefer the candidate whose extent sits nearest the middle of the plausible
        # range on a log scale — it discriminates without pretending to be exact.
        centre = math.sqrt(PLAUSIBLE_M[0] * PLAUSIBLE_M[1])
        scored.append((abs(math.log(real / centre)), code))

    evidence = {
        "header": declared, "measurement": measurement, "extent_span": span,
        "text_heights": text_heights[:5], "candidates": [c for _, c in sorted(scored)],
    }

    if len(scored) == 1:
        code = scored[0][1]
        name, factor = CANDIDATES[code]
        return UnitDecision(code, name, factor, "inferred", evidence)

    name, factor = CANDIDATES[default_code]
    return UnitDecision(default_code, name, factor, "default", evidence)


if __name__ == "__main__":
    decision = decide_units("unlabelled.dxf")
    print(asdict(decision))
    if decision.confidence != "declared":
        print("ASSUMPTION APPLIED — record this with the output")
The same extent span under four candidate units One drawing whose extents span 240 000 units, converted under four candidate units. Three of the four produce a real-world size that is absurd for a site plan and are rejected outright; the surviving candidate is what the heuristic reports. The rejection is the useful part — the heuristic narrows, and a stated policy decides what remains. Interpreted as Real-world span Plausible for a site? millimetres 240 m yes centimetres 2.4 km no feet 73 km no metres 240 km no Three candidates are rejected by magnitude alone, before any other evidence is weighed.

Key implementation notes:

  • A single surviving candidate is reported as inferred; several surviving candidates fall through to the configured default. The heuristic narrows, the policy decides.
  • The plausibility range is a stated constant rather than an implicit belief, so it can be adjusted for a domain where 20 km is not the ceiling.
  • Text heights are collected and reported but not scored. They are the most useful evidence for a human reviewing an ambiguous case, and the least amenable to a rule.
  • The confidence field is the point of the whole function. Downstream code and audit logs can distinguish a declared unit from an assumed one, which a bare scale factor cannot express.

Compatibility Matrix

Component Supported range Notes
ezdxf >=1.1.0 header access, entity query
DXF revision R12 – R2018 the units header exists from R12
$MEASUREMENT present or absent used only as a family filter
Extents $EXTMIN/$EXTMAX fall back to computing from geometry if absent
Policy default configurable never implicit

Fallback Strategies

1. Extents are zero or absent. Some exporters omit them. Compute the bounding box from modelspace geometry instead; it costs a pass over the entities and gives the same signal.

Three confidence levels, carried downstream The three outcomes the resolver can produce and what each licenses. A declared header value is a fact. A single surviving candidate is an inference. Anything else is the configured policy default. Carrying the level with the factor lets downstream code and an audit distinguish them, which a bare number cannot express. declared the header said so inferred one survivor default policy applied, logged no candidate several survive Being wrong is sometimes unavoidable; being wrong invisibly is not.

2. Every candidate is implausible. Usually the drawing contains a stray entity at a huge coordinate, dragging the extents. Compute a robust span from a coordinate percentile rather than from the absolute extremes.

3. Two candidates survive. Expected — the configured default takes it, and the evidence record shows what was rejected. Reviewing a sample of these is how the default gets tuned for a client.

4. The drawing is a detail rather than a plan. A 300 mm bracket detail has extents outside the site range and will score badly. Set the plausible range from the expected content of the pipeline’s input, or route details through a separate policy.

5. The assumption turns out wrong. Because it was recorded, the affected outputs are identifiable and re-runnable. That is the entire benefit of preferring an auditable answer to a confident one.

FAQ

Can $MEASUREMENT tell me the unit?

No. It distinguishes imperial from metric drafting settings and nothing more — it does not name millimetres, metres or feet. It is useful only as a tiebreaker between candidates from different families, and treating it as the answer is how an inch drawing gets read as feet.

How reliable is the extents heuristic?

Reliable enough to reject the absurd, not reliable enough to decide alone. It rules out candidates that make a building 40 kilometres across, which is most of them, and it cannot distinguish a large site in metres from a small one in kilometres. Treat it as a filter that narrows the choice, with a configured default deciding what remains.

Should the pipeline just pick the best guess and continue?

It should apply an explicit policy and record what it applied. The failure mode to avoid is not being wrong — sometimes there is no way to be right — it is being wrong invisibly. A logged, reviewable assumption can be corrected later; a silent default cannot even be found.